diff --git a/docs/design-docs/autonomy.md b/docs/design-docs/autonomy.md index 962157623..6f0e8ec03 100644 --- a/docs/design-docs/autonomy.md +++ b/docs/design-docs/autonomy.md @@ -40,7 +40,8 @@ The cortex assembles the autonomy channel's context before each wake. It gets: - **Memory bulletin** — the cortex's current knowledge synthesis. - **Working memory** — recent system events. What's been happening across all channels. - **Wake events** — what pulled this run forward, if anything: the wake's name, instructions, and payload for each pending event since the last run. Surfaced first, because they are usually why the run exists. -- **Task state** — all active tasks: ready, in-progress, backlog, pending_approval. Full detail on each, including all comments. +- **Task state** — all active tasks: ready, in-progress, backlog, pending_approval, each with its comment count and last enrichment time. +- **Enrichment queue** — the ordered selection for this run, with the recent comments on each entry inlined. Full comment threads are rendered here rather than across the whole board, so context stays bounded as the board grows. - **Goals** — all active goals with descriptions and notes. Background context and direction, not a work queue. See [`goals.md`](goals.md). - **Active workers** — what's currently running so it doesn't duplicate work. - **Last few run summaries** — the `autonomy_complete` output from its previous runs, with timestamps. This is the primary continuity mechanism. @@ -56,16 +57,23 @@ All tasks require human approval before execution. `pending_approval` tasks are The autonomy channel reasons about which tasks to prioritise given goal context and current system state — it is not a FIFO queue. During a run it can: -- **Enrich pending tasks** — spawn investigation workers, reason about their findings, and add comments to tasks with synthesised results. A task that arrived as a title becomes a fully researched brief before the user ever approves it. +- **Enrich pending tasks** — spawn investigation workers, reason about their findings, and record synthesised results with `add_task_comment`. A task that arrived as a title becomes a fully researched brief before the user ever approves it. - **Execute ready tasks** — tasks the user has approved. Uses execution tools directly (shell, file, browser) with no forced delegation. Workers available for genuine parallelism. - **Create new tasks** — identifies follow-on work and adds it to `pending_approval`. The agent proposes; the user decides. - **Update task metadata** — priority, blockers, progress notes. +- **Link work to goals** — set `goal_id` when creating a task toward a goal, and record where a goal now stands in its `notes`. What it **cannot** do: - Reply to users (no `reply` tool) - Execute tasks that are still in `pending_approval` - Create cron jobs - Spawn other autonomy channels +- Change a goal's status. `goal_update` is registered without the `status` + field for autonomy runs, so completing or abandoning a goal is unreachable + rather than merely discouraged. + +At `observe` the task surface is not registered at all: the run reads and +summarises, and has no tool with which to create, update, comment, or claim. --- @@ -79,33 +87,55 @@ Both the agent and the user can comment on a task. This makes tasks a shared wor ```sql CREATE TABLE task_comments ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, author_type TEXT NOT NULL, -- 'agent' | 'user' | 'worker' - author_id TEXT, -- user_id for users, worker_id for workers, null for agent + author_id TEXT, -- agent id, user id, or worker id body TEXT NOT NULL, -- synthesised comment text (2-5 lines) worker_id TEXT, -- if this comment summarises a worker run, links to that worker - metadata TEXT DEFAULT '{}', + metadata TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -CREATE INDEX task_comments_task ON task_comments(task_id, created_at); +CREATE INDEX task_comments_task ON task_comments(task_id, seq); +CREATE INDEX task_comments_author ON task_comments(task_id, author_type, created_at); ``` +`seq` is the ordering key, not `created_at`: two comments inside the same +millisecond would otherwise have no defined order, and pagination cursors would +skip or repeat rows. `seq` is also the cursor the list API returns. + +Deletion is explicit as well as declared. `ON DELETE CASCADE` only fires when +the connection has `PRAGMA foreign_keys` on, so `TaskStore::delete` removes a +task's comments in the same transaction as the task. + +Comment bodies are capped at 4000 bytes. A finding longer than that is a +transcript, and transcripts belong behind the `worker_id` link. + `worker_id` links a comment to a specific worker run. The UI renders it as a pill on the comment — click to expand the full worker output. The comment body is always the agent's synthesised 2-5 line summary; the worker output is available on demand, never inlined. ### `add_task_comment` tool -Available to the autonomy channel and to workers (via the task tools toolset). +Comments are append-only. There is no edit and no delete: the store exposes +neither, and the UI offers no affordance for either. ```rust -struct AddTaskCommentInput { - task_id: String, +struct AddTaskCommentArgs { + task_number: i64, // #N, matching every other task tool body: String, // synthesised finding — 2-5 lines worker_id: Option, // tag the worker whose output this summarises } ``` +Registered for the autonomy channel (at `suggest` and above), for branches, for +the cortex chat, and for task workers. A worker's registration drops the +`worker_id` field and stamps its own id, so a worker cannot misattribute a +finding to another run. + +Commenting is also where autonomous ownership is settled — see +[Task Ownership](#task-ownership). + ### Enrichment Pattern ``` @@ -122,7 +152,19 @@ The autonomy channel system prompt instructs: investigate and comment freely; ne ### Worker Briefing -When a `ready` task is eventually executed, `WorkerContextMode::Briefed` pulls the task's comments as part of the briefing synthesis alongside memory recall and working memory events. The executing worker walks in knowing what was investigated, what solution was proposed, and what the user said — without re-doing any of the research. +When a `ready` task is executed, its comments are appended to the worker's task +prompt as a "Prior findings" block: oldest first, capped at 12 comments, 600 +bytes each, 4000 bytes total. The executing worker walks in knowing what was +investigated, what solution was proposed, and what the user said — without +re-doing any of the research. + +`WorkerContextMode::Briefed` does not exist in source. `WorkerContextMode` is a +struct of `history` / `memory` / `wiki_write` modes, and the enum in +[`worker-briefing.md`](worker-briefing.md) was never built. Comments are +injected at the ready-task pickup path instead, which is where a worker is +actually bound to a task — the only place the briefing has a task's comments to +carry. Worker transcripts are never inlined; they stay behind the `worker_id` +link on each comment. --- @@ -140,7 +182,7 @@ ALTER TABLE tasks ADD COLUMN last_enriched_at TEXT; Tasks have an `assigned_agent_id` field. Once an agent claims a task, no other agent can work on it. Ownership is enforced at the query level and set atomically when a task is first enriched or executed. -The global tasks table currently declares `assigned_agent_id TEXT NOT NULL` with assignment at creation time, so the unowned-task model requires making the column nullable. That migration touches the global database and every task consumer; it ships as its own change ahead of this system, not inside it. +`assigned_agent_id` is nullable as of `20260809000001_tasks_nullable_assignment`, so tasks can exist unowned until an agent claims them. ```sql -- Atomic claim: only succeeds if still unassigned or already assigned to this agent @@ -148,7 +190,14 @@ UPDATE tasks SET assigned_agent_id = ?1 WHERE id = ?2 AND (assigned_agent_id IS NULL OR assigned_agent_id = ?1) ``` -If the UPDATE affects 0 rows, another agent claimed it first — skip and move on. +If the UPDATE affects 0 rows, another agent claimed it first — the tool returns +a skip ("task #N was claimed by first"), and the run moves on. + +The claim happens when the agent *acts* on a task, not when it lists one: +`add_task_comment` claims before it writes, and `claim_next_ready` takes +assignment and `in_progress` in one guarded UPDATE. Surveying the board claims +nothing, and `observe` runs get no mutation tools at all, so they cannot claim +by any path. `claim_unowned` is an agent-level config flag (default: `true`). Set to `false` for agents that should only work on tasks explicitly assigned to them — useful in multi-agent setups where task routing is intentional. Once claimed, ownership is permanent. Reassignment is user-initiated only. @@ -163,7 +212,17 @@ On wake, tasks are ordered by: Rule 4 prevents just-worked-on tasks from floating to the top next wake. Rule 1 clears that exclusion when the user responds — natural gate, no separate flag needed. -The channel selects up to `max_tasks_per_run` from this ordered list, reasoning about which are most valuable given goal context. Not a mechanical top-N pick. All queries filter to `assigned_agent_id = this_agent OR assigned_agent_id IS NULL` (when `claim_unowned = true`). +This ordering is settled in SQL (`TaskStore::select_for_enrichment`) and +rendered into the run briefing as an **Enrichment Queue**, so the channel opens +with a list that already excludes last run's work. Visibility filters to +`assigned_agent_id = this_agent OR assigned_agent_id IS NULL` (the latter only +when `claim_unowned = true`). + +`max_tasks_per_run` is enforced, not requested: `add_task_comment` holds the +run's allowance and refuses a task beyond it. The allowance is spent per task, +so a run can keep commenting on work it already started. Within the allowance +the channel still reasons about which tasks are most valuable given goal +context — it is not a mechanical top-N pick. ### Run History as Context @@ -269,19 +328,32 @@ Enforced at startup and on config reload — autonomy does not start if any rule - Task retry/failure handling (3 strikes → `failed`, working memory error event) - All config fields + validation -**Phase 2 — Task Comments** +**Phase 2 — Task Comments — shipped** - `task_comments` table migration -- `add_task_comment` tool (autonomy channel + workers) -- Task comments included in task state context on wake -- Task comments pulled into `WorkerContextMode::Briefed` pipeline +- `add_task_comment` tool (autonomy channel, branches, cortex chat, task workers) +- Task comments in the wake briefing: counts on every surveyed task, full + recent comments on every enrichment-queue entry +- Task comments injected into the executing worker's briefing at ready-task + pickup, bounded per comment and in total +- `last_enriched_at` column, transactional with the comment that sets it +- Atomic claim on first comment and on ready-task pickup - API endpoints: list and create comments per task - UI: chronological comments on task detail, worker pill with expandable full output **Phase 3 — Polish** - Autonomy UI surface: run history, last wake time, active enrichment progress -- "Quiet while active" flag: suppress autonomy wakes when user channels have been recently active - Autonomy outcomes surfaced to relevant user channels via working memory synthesis +**Rejected — "quiet while active"** + +Suppressing autonomy wakes while user channels have been recently active is +rejected, not deferred. Enrichment is exactly the work that is most useful +while the user is around to react to it, and the wake already carries the +signals that matter: a user comment on a task pulls the next run forward +rather than pushing it away. A global "someone is talking, stand down" flag +would suppress the run for reasons unrelated to the task it was going to work +on. Do not reintroduce it. + --- ## Non-Goals diff --git a/docs/design-docs/goals.md b/docs/design-docs/goals.md index e0611c1ca..d99fd48ac 100644 --- a/docs/design-docs/goals.md +++ b/docs/design-docs/goals.md @@ -114,7 +114,10 @@ No token budget — autonomy channels and goal review get the full picture. - `goal_create(title, description?, priority?, due_date?)` — create a new goal - `goal_update(id, status?, priority?, due_date?, notes?, metadata_patch?)` — update fields - `goal_list(status?)` — list goals, optionally filtered by status -- `goal_note(id, notes)` — update the progress notes field (shorthand for `goal_update`) + +There is no separate `goal_note` tool: `goal_update(id, notes)` is the notes +path, and a second tool for one field would be a synonym rather than a +capability. ### Channel Tools (read-only) @@ -124,7 +127,11 @@ Channels get `goal_list` only. They can read goals and reference them in convers Goals are not completed by tool — they're completed by the user via the API or UI. The agent can call `goal_update(status: "completed")` only with explicit user instruction. The goal review process does not call this automatically. -What the goal review *can* do: set `notes` to "All linked tasks complete — ready for your review" when it detects all tasks are done. This surfaces the completion candidate to the user without presuming to close it. +What the autonomy run *does* do: prepend "All linked tasks complete — ready for +your review." to the goal's notes when every linked task is `done`. This +surfaces the completion candidate to the user without presuming to close it. +The write is programmatic, so a goal cannot be flagged by a model that +miscounted, and cannot be closed by one that overreached. --- @@ -192,15 +199,22 @@ A goal without tasks is a signal the autonomy channel acts on — it creates `pe **Phase 1 — Data Model + Tools** - `goals` table migration - `goal_id` FK on tasks migration -- `goal_create`, `goal_update`, `goal_list`, `goal_note` tools +- `goal_create`, `goal_update`, `goal_list` tools - Active goals injected into channel system prompt (short format) - API endpoints: CRUD for goals -**Phase 2 — Autonomy Integration** +**Phase 2 — Autonomy Integration — shipped** - Autonomy channel receives all active goals in extended format on wake -- Autonomy channel uses `goal_id` when creating tasks from goals -- Autonomy channel updates `notes` as progress is made -- Autonomy channel sets `notes` when all linked tasks are complete +- Autonomy channel uses `goal_id` when creating tasks from goals. `task_create` + resolves the id against the goal store before insert, so an unknown goal is a + tool error rather than a dangling link. +- Autonomy channel updates `notes` as progress is made, through a `goal_update` + registration with the `status` field removed +- The ready-for-review marker is written deterministically at the end of each + run, not left to the model: `mark_goals_ready_for_review` prepends the marker + to any active goal whose linked tasks are all `done`, keeping the agent's own + progress notes beneath it. It is idempotent, treats `failed` as work + remaining, and never touches goal status. **Phase 3 — UI** - Goals tab: list, create, detail panel, linked task counts diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e75720f62..fe0e6e719 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1076,11 +1076,44 @@ export interface TaskItem { created_by: string; approved_at?: string; approved_by?: string; + last_enriched_at?: string; created_at: string; updated_at: string; completed_at?: string; } +export type TaskCommentAuthor = "agent" | "user" | "worker"; + +export interface TaskComment { + seq: number; + id: string; + task_id: string; + author_type: TaskCommentAuthor; + author_id?: string; + body: string; + worker_id?: string; + metadata: Record; + created_at: string; +} + +export interface TaskCommentListResponse { + comments: TaskComment[]; + total: number; + next_cursor?: number; +} + +export interface TaskCommentResponse { + comment: TaskComment; +} + +export interface CreateTaskCommentRequest { + author_type?: TaskCommentAuthor; + author_id?: string; + body: string; + worker_id?: string; + metadata?: Record; +} + export interface TaskListResponse { tasks: TaskItem[]; } @@ -1103,6 +1136,7 @@ export interface CreateTaskRequest { priority?: TaskPriority; subtasks?: TaskSubtask[]; metadata?: Record; + goal_id?: string; source_memory_id?: string; created_by?: string; } @@ -2575,6 +2609,27 @@ export const api = { if (!response.ok) throw new Error(`API error: ${response.status}`); return response.json() as Promise; }, + listTaskComments: (taskNumber: number, params?: { after?: number; limit?: number }) => { + const search = new URLSearchParams(); + if (params?.after !== undefined) search.set("after", String(params.after)); + if (params?.limit) search.set("limit", String(params.limit)); + const query = search.toString(); + return fetchJson( + query ? `/tasks/${taskNumber}/comments?${query}` : `/tasks/${taskNumber}/comments`, + ); + }, + createTaskComment: async ( + taskNumber: number, + request: CreateTaskCommentRequest, + ): Promise => { + const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, assignTask: async (taskNumber: number, assignedAgentId: string): Promise => { const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/assign`, { method: "POST", diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 11399ec3d..568932b4e 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -2487,6 +2487,24 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/comments` — list a task's comments, oldest first. */ + get: operations["list_task_comments"]; + put?: never; + /** `POST /tasks/{number}/comments` — append a comment to a task. */ + post: operations["create_task_comment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/execute": { parameters: { query?: never; @@ -3469,11 +3487,22 @@ export interface components { path: string; remote_url?: string | null; }; + CreateTaskCommentRequest: { + author_id?: string | null; + /** @description Defaults to `user` — the interface is the human's comment surface. */ + author_type?: string | null; + body: string; + metadata?: unknown; + /** @description Worker run this comment summarises, when applicable. */ + worker_id?: string | null; + }; CreateTaskRequest: { /** @description Agent assigned to execute. Defaults to `owner_agent_id`. */ assigned_agent_id?: string | null; created_by?: string | null; description?: string | null; + /** @description Goal this task contributes to. */ + goal_id?: string | null; metadata?: unknown; /** @description Agent that owns (created) this task. */ owner_agent_id: string; @@ -4569,6 +4598,8 @@ export interface components { /** @description Goal this task contributes to, when linked. */ goal_id?: string | null; id: string; + /** @description Last time an autonomy run enriched this task. Drives selection order. */ + last_enriched_at?: string | null; metadata: unknown; owner_agent_id: string; priority: components["schemas"]["TaskPriority"]; @@ -4585,6 +4616,43 @@ export interface components { message: string; success: boolean; }; + TaskComment: { + author_id?: string | null; + author_type: components["schemas"]["TaskCommentAuthor"]; + body: string; + created_at: string; + id: string; + metadata: unknown; + /** + * Format: int64 + * @description Monotonic sequence number. Stable pagination cursor. + */ + seq: number; + task_id: string; + /** @description Worker run this comment summarises, when it summarises one. */ + worker_id?: string | null; + }; + /** + * @description Who wrote a comment. + * @enum {string} + */ + TaskCommentAuthor: "agent" | "user" | "worker"; + TaskCommentListResponse: { + comments: components["schemas"]["TaskComment"][]; + /** + * Format: int64 + * @description Cursor for the next page, absent when the page is the last one. + */ + next_cursor?: number | null; + /** + * Format: int64 + * @description Total comments on the task, independent of this page. + */ + total: number; + }; + TaskCommentResponse: { + comment: components["schemas"]["TaskComment"]; + }; TaskListResponse: { tasks: components["schemas"]["Task"][]; }; @@ -11245,6 +11313,93 @@ export interface operations { }; }; }; + list_task_comments: { + parameters: { + query?: { + /** @description Resume after this comment `seq`. Comments are returned oldest-first. */ + after?: number | null; + limit?: number; + }; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskCommentListResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + create_task_comment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTaskCommentRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskCommentResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; execute_task: { parameters: { query?: never; diff --git a/interface/src/components/TaskComments.tsx b/interface/src/components/TaskComments.tsx new file mode 100644 index 000000000..138517c52 --- /dev/null +++ b/interface/src/components/TaskComments.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faRobot, faUser, faGear, faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { Badge, Button } from "@spacedrive/primitives"; +import { api, type TaskComment, type TaskCommentAuthor } from "@/api/client"; +import { useLiveContext } from "@/hooks/useLiveContext"; + +const PAGE_SIZE = 50; + +/** Longest comment the backend accepts, in bytes. Mirrors MAX_COMMENT_BODY_BYTES. */ +const MAX_BODY_BYTES = 4000; + +const AUTHOR_ICON: Record = { + user: faUser, + agent: faRobot, + worker: faGear, +}; + +const AUTHOR_VARIANT: Record = { + user: "info", + agent: "success", + worker: "default", +}; + +function formatTimestamp(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); +} + +/** + * A worker-authored or worker-tagged comment links back to the run that + * produced it. The body stays the agent's summary; the full output is fetched + * only when the user asks for it. + */ +function WorkerOutput({ agentId, workerId }: { agentId: string; workerId: string }) { + const [expanded, setExpanded] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ["worker-detail", agentId, workerId], + queryFn: () => api.workerDetail(agentId, workerId), + enabled: expanded, + staleTime: 60_000, + }); + + return ( +
+ + + {expanded && ( +
+ {isLoading ? ( + Loading worker output… + ) : error ? ( + + Worker run is no longer available. + + ) : ( +
+							{data?.result?.trim() || "This worker recorded no output."}
+						
+ )} +
+ )} +
+ ); +} + +function CommentRow({ + comment, + agentId, + resolveAgentName, +}: { + comment: TaskComment; + agentId?: string; + resolveAgentName?: (agentId: string) => string; +}) { + const author = + comment.author_type === "agent" && comment.author_id + ? (resolveAgentName?.(comment.author_id) ?? comment.author_id) + : comment.author_type === "worker" + ? "Worker" + : (comment.author_id ?? "You"); + + return ( +
  • +
    + + + {author} + + + {formatTimestamp(comment.created_at)} + +
    +

    + {comment.body} +

    + {comment.worker_id && agentId && ( + + )} +
  • + ); +} + +/** + * Chronological comment thread for a task, with a composer. + * + * Comments are append-only: there is no edit or delete affordance because the + * store has no such operation. `agentId` enables the worker-output links; it is + * the agent the task is assigned to, which is the pool a worker run lives in. + */ +export function TaskComments({ + taskNumber, + agentId, + resolveAgentName, +}: { + taskNumber: number; + agentId?: string; + resolveAgentName?: (agentId: string) => string; +}) { + const queryClient = useQueryClient(); + const { taskEventVersion } = useLiveContext(); + const queryKey = ["task-comments", taskNumber]; + + // A comment from an autonomy run arrives over SSE as a task event. + const previousVersion = useRef(taskEventVersion); + useEffect(() => { + if (taskEventVersion !== previousVersion.current) { + previousVersion.current = taskEventVersion; + void queryClient.invalidateQueries({ queryKey }); + } + }, [taskEventVersion, queryClient, taskNumber]); + + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: () => api.listTaskComments(taskNumber, { limit: PAGE_SIZE }), + }); + + const [draft, setDraft] = useState(""); + + const createMutation = useMutation({ + mutationFn: (body: string) => api.createTaskComment(taskNumber, { body }), + onSuccess: () => { + setDraft(""); + void queryClient.invalidateQueries({ queryKey }); + }, + }); + + const handleSubmit = useCallback(() => { + const body = draft.trim(); + if (body.length < 4 || body.length > MAX_BODY_BYTES) return; + createMutation.mutate(body); + }, [draft, createMutation]); + + const comments = data?.comments ?? []; + const total = data?.total ?? 0; + const hasMore = data?.next_cursor !== undefined && data?.next_cursor !== null; + + return ( +
    +

    + Comments{total > 0 ? ` (${total})` : ""} +

    + + {isLoading ? ( +

    Loading comments…

    + ) : error ? ( +

    Failed to load comments.

    + ) : comments.length === 0 ? ( +

    + No comments yet. Findings from autonomy runs land here. +

    + ) : ( + <> +
      + {comments.map((comment) => ( + + ))} +
    + {hasMore && ( +

    + Showing the first {comments.length} of {total}. +

    + )} + + )} + +
    +