diff --git a/change-logs/2026/06/10/feature-agent-completion-request.md b/change-logs/2026/06/10/feature-agent-completion-request.md new file mode 100644 index 00000000..52935422 --- /dev/null +++ b/change-logs/2026/06/10/feature-agent-completion-request.md @@ -0,0 +1 @@ +AI agents can now request task completion via `dev3 task move --status completed`. Instead of moving the task directly, the CLI blocks (up to 10 minutes) while the app shows a visually distinct AI-initiated approval dialog — approving completes the task and destroys the session, declining returns exit code 6 to the agent so it knows the user said no. `cancelled` remains fully forbidden via CLI. diff --git a/decisions/067-agent-completion-request.md b/decisions/067-agent-completion-request.md new file mode 100644 index 00000000..66d40b71 --- /dev/null +++ b/decisions/067-agent-completion-request.md @@ -0,0 +1,21 @@ +# 067 — Agent-initiated task completion via blocking CLI approval + +## Context + +Only the user could move a task to `completed` (it destroys the worktree + tmux session); the CLI blocked it client-side in `DESTRUCTIVE_STATUSES`. Agents needed a way to signal "I'm fully done" — without being able to silently kill their own session. The agent must also learn from the CLI response whether the user approved or declined. + +## Decision + +`dev3 task move --status completed` now sends `task.requestCompletion` over the CLI socket and **blocks up to 10 minutes** (client-side timeout, `src/cli/commands/task.ts` → `requestCompletion`). The bun handler (`src/bun/cli-socket-server.ts`) registers a pending request in `src/bun/completion-requests.ts` and pushes `agentCompletionRequested` to the renderer, which shows a danger-styled `confirm()` with an "AI agent request" badge and accent border (`agentInitiated` option in `src/mainview/confirm.tsx`, listener in `App.tsx`). The renderer answers via the `respondToAgentCompletionRequest` RPC; on approve the handler runs the normal `moveTask` → completed, on decline the CLI exits with the new documented code 6 (`CLI_EXIT_CODE_COMPLETION_DECLINED`). `cancelled` stays fully forbidden via CLI. + +## Risks + +- Pending requests are in-memory only (user chose no persistence): an app restart drops the dialog and the CLI times out. Acceptable — the agent can simply retry. +- No server-side timeout: if the CLI dies, the dialog stays; a later user approval still completes the task (the write to the dead socket fails harmlessly). This is intentional — an AFK user's approval must not be lost. +- A repeat request for the same task joins the existing pending decision (`isNew` flag) instead of spawning a duplicate dialog. + +## Alternatives considered + +- New command `dev3 task request-completion` — rejected; agents already know `task move`, reuse keeps the surface minimal. +- Fire-and-forget push + persisted board badge — rejected by the user; live blocking dialog only, agent gets the verdict in the same invocation. +- Auto-adding a note on decline — rejected by the user; the exit-code-6 message tells the agent to keep working. diff --git a/docs/cli-exit-codes.md b/docs/cli-exit-codes.md index 997e98b2..2cfc7c78 100644 --- a/docs/cli-exit-codes.md +++ b/docs/cli-exit-codes.md @@ -10,6 +10,7 @@ Public `dev3` CLI exit codes are defined in `src/shared/cli-exit-codes.ts`. | `3` | `CLI_EXIT_CODE_USAGE_ERROR` | The CLI invocation was invalid: bad command, bad subcommand, or missing required args. | | `4` | `CLI_EXIT_CODE_INTERNAL_ERROR` | An unexpected internal CLI failure escaped normal command handling. | | `5` | `CLI_EXIT_CODE_GUI_DEPS_MISSING` | `dev3 gui` cannot launch because system libraries (GTK, WebKit, etc.) are missing. The CLI prints the install command for the detected distro and exits with this code so wrappers can detect it. | +| `6` | `CLI_EXIT_CODE_COMPLETION_DECLINED` | `dev3 task move --status completed` asked the user for approval and the user declined. The task keeps its current status and the session stays alive. | Rules: diff --git a/docs/ux/UX_DECISIONS.md b/docs/ux/UX_DECISIONS.md index 0426ce6f..e2ca5657 100644 --- a/docs/ux/UX_DECISIONS.md +++ b/docs/ux/UX_DECISIONS.md @@ -61,3 +61,9 @@ Append-only log of UX architecture decisions. Each entry: date, decision, ration - **Decision:** Below a `matchMedia("(max-width: 1600px)")` breakpoint (new `useCompact()` hook), the `GlobalHeader` action cluster and the `TaskInfoPanel` toolbar switch to a compact layout: text labels collapse to icon-only (tooltips kept), and the header's three low-frequency external actions (Website, Report, Change Log) fold into a single "More" (`⋯`) overflow dropdown. Diff badge and status stay labelled. Above the breakpoint the layout is unchanged. - **Rationale:** On a 14" MacBook (≤1512pt) the labelled, `flex-shrink-0` button rows overflowed and overlapped; on 16" (1728pt) they fit. 1600px cleanly separates the two at default scaling and also fires on window resize. Per the action taxonomy, the rare external links are the correct overflow candidates; frequent/destination controls stay visible as icons. Viewport-based v1; a content-aware (ResizeObserver) upgrade is the planned v2 since a long breadcrumb title can still crowd the header near the boundary. No flex-wrap (vertical space is scarce in a terminal-centric app). - **Status:** `Observed` (implemented: `useCompact.ts`, `GlobalHeader.tsx`, `TaskInfoPanel.tsx`, `PreventSleepToggle.tsx`, `GitPullButton.tsx`; keys `header.moreActions`/`header.githubLabel` in en/ru/es). See decision record 063. + +## 2026-06-10 — AI-initiated task completion uses a blocking, visually distinct confirm dialog + +- **Decision:** `dev3 task move --status completed` from an agent does not move the task; it opens the existing imperative `confirm()` modal with a new `agentInitiated` treatment — accent border (`border-accent/40`) plus a badge pill (robot glyph + "AI agent request") — and `danger`-role confirm button labelled "Complete task", cancel labelled "Keep session" with autofocus. The CLI blocks (≤10 min) for the verdict; decline returns documented exit code 6 so the agent learns the user said no. No persistence, no board badge — ephemeral live dialog only (explicit user choice). `cancelled` stays CLI-forbidden. +- **Rationale:** Completing destroys the worktree + tmux session, so the action is destructive and must keep human approval; the AI-identity badge prevents the user from mistaking it for a routine self-initiated confirm and accidentally approving. Reusing the Modal surface and the `task move` verb adds zero new UI chrome and zero new CLI surface. +- **Status:** `Observed` (implemented: `confirm.tsx` `agentInitiated`, `App.tsx` listener, `completion-requests.ts`, `cli-socket-server.ts` `task.requestCompletion`, `task.ts` `requestCompletion`, exit code 6). See decision record 067 and `feature-plans/agent-completion-request.md`. diff --git a/docs/ux/UX_MANIFEST_CHANGELOG.md b/docs/ux/UX_MANIFEST_CHANGELOG.md index 45335632..56da224c 100644 --- a/docs/ux/UX_MANIFEST_CHANGELOG.md +++ b/docs/ux/UX_MANIFEST_CHANGELOG.md @@ -26,3 +26,7 @@ Documented the inspector header as a 2×2 quickbar grid (Context / Session-Agent ## 2026-06-03 — macOS dock-persistence + unified quit-confirmation modal Added a UX decision documenting `exitOnLastWindowClosed: false` (closing the last window keeps the app in the dock, reopened on dock-click) and the React quit-confirmation modal driven by the main-process `before-quit` gate, covering Cmd+Q (via `requestQuit`), menu Quit, and dock Quit. A window-less quit reopens a window that pulls the pending flag on mount to show the dialog reliably. Plus the Cmd+Shift+N New Window shortcut. No new visible buttons or tokens — conforms to the Modal surface and destructive-button-role policy. Decision records 044, 060, 061. + +## 2026-06-10 — Agent completion request (AI-initiated destructive confirm) + +Documented the agent-initiated task-completion flow: CLI-triggered blocking approval via the existing `confirm()` Modal with a new `agentInitiated` visual treatment (accent border + robot badge), danger-role approve, autofocused safe cancel, CLI exit code 6 on decline. New feature plan `feature-plans/agent-completion-request.md`, UX decision appended, decision record 067. No new surfaces, nav items, or budget changes. diff --git a/docs/ux/feature-plans/agent-completion-request.md b/docs/ux/feature-plans/agent-completion-request.md new file mode 100644 index 00000000..7984db41 --- /dev/null +++ b/docs/ux/feature-plans/agent-completion-request.md @@ -0,0 +1,31 @@ +# Feature plan — Agent-initiated task completion request + +## Feature classification + +- **User job:** decide whether an agent that claims to be done may complete its task (destroying worktree + tmux session). +- **Owning object:** Task. **Workflow:** task lifecycle (status transitions). +- **Feature class:** destructive action with mandatory human approval, AI-initiated. +- **Scope:** single task. **Frequency:** occasional (end of each agent task). **Risk:** destructive (session + worktree loss). + +## Placement + +- **Trigger:** CLI only — `dev3 task move --status completed`. No new UI entry point; the user-side trigger remains the existing drag-to-Completed / UI flows. +- **Surface:** the existing imperative `confirm()` Modal (`src/mainview/confirm.tsx`), same surface as the branch-merged prompt. No new surface, no nav change, no toolbar buttons — zero impact on complexity budgets. +- **Rejected placements:** persistent board badge / inbox (user explicitly chose ephemeral live dialog); toast (not blocking, too easy to miss for a destructive decision); native dialog (banned — remote/browser mode). + +## Action hierarchy & tokens + +- **Approve ("Complete task"):** semantic role `destructive`, concrete variant — the dialog's `danger` confirm button (`bg-danger`). Never primary-styled. +- **Cancel ("Keep session"):** semantic role `secondary`; receives `autoFocus` so Enter defaults to the safe choice. +- **AI identity treatment:** `agentInitiated` option renders an accent badge pill (robot glyph `\u{F06A9}` + "AI agent request") and `border-accent/40` dialog border — visually distinct from user-initiated confirms. +- Backdrop click / Esc = cancel. Triple protection against accidental approval: danger styling, cancel autofocus, explicit badge. + +## Interaction + +- Agent runs the CLI → blocking socket request (10-min client timeout) → push `agentCompletionRequested` → dialog. Approve → task moves to completed (normal `moveTask` path, `taskUpdated` push updates the board, navigation leaves the doomed task screen). Decline → CLI exit code 6 with guidance text for the agent. +- Duplicate requests for the same task join the pending decision — only one dialog ever shows. +- States: app window absent → CLI gets an immediate error; task already completed/cancelled → error; CLI timeout → dialog may remain, late approval still completes. + +## i18n + +Keys `app.agentCompletion*` + `confirmDialog.agentBadge` in en/ru/es `common.ts`. diff --git a/src/bun/__tests__/cli-socket-completion-request.test.ts b/src/bun/__tests__/cli-socket-completion-request.test.ts new file mode 100644 index 00000000..ec16a62c --- /dev/null +++ b/src/bun/__tests__/cli-socket-completion-request.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Project, Task, CliRequest } from "../../shared/types"; + +// ---- Mocks (same boundary set as cli-socket-handlers.test.ts) ---- + +vi.mock("../data", () => ({ + loadProjects: vi.fn(), + getProject: vi.fn(), + loadTasks: vi.fn(), + getTask: vi.fn(), + addTask: vi.fn(), + updateTask: vi.fn(), + updateProject: vi.fn(), +})); + +vi.mock("../git", () => ({ + createWorktree: vi.fn(), + removeWorktree: vi.fn(), +})); + +vi.mock("../pty-server", () => ({ + destroySession: vi.fn(), +})); + +vi.mock("../rpc-handlers/tmux-pty", () => ({ + runDevServer: vi.fn(), + stopDevServer: vi.fn(), + restartDevServer: vi.fn(), + getDevServerStatus: vi.fn(), +})); + +vi.mock("../rpc-handlers", () => { + const ACTIVE = ["in-progress", "user-questions", "review-by-user", "review-by-ai"]; + return { + isActive: vi.fn((status: string) => ACTIVE.includes(status)), + activateTask: vi.fn(), + moveTask: vi.fn(), + runCleanupScript: vi.fn(), + emitTaskSound: vi.fn(), + getPushMessage: vi.fn(() => null), + triggerColumnAgentIfNeeded: vi.fn(), + notifyWatchedTaskStatusChange: vi.fn(), + }; +}); + +vi.mock("../logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("../paths", () => ({ + DEV3_HOME: "/tmp/test-dev3", +})); + +vi.mock("../socket-backpressure", () => ({ + flushAndEnd: vi.fn(), + drainSocket: vi.fn(), + pendingWrites: new Map(), +})); + +vi.mock("../settings", () => ({ + loadSettings: vi.fn(() => ({ updateChannel: "stable", taskDropPosition: "top" })), + saveSettings: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readdirSync: vi.fn(() => []), + unlinkSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +import * as data from "../data"; +import { moveTask, getPushMessage } from "../rpc-handlers"; +import { resolveCompletionRequest, _resetCompletionRequestsForTests } from "../completion-requests"; + +const { handleRequest } = await import("../cli-socket-server"); + +// ---- Helpers ---- + +function makeProject(overrides?: Partial): Project { + return { + id: "proj-1", + name: "Test Project", + path: "/tmp/test-project", + setupScript: "", + devScript: "", + cleanupScript: "", + defaultBaseBranch: "main", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeTask(overrides?: Partial): Task { + return { + id: "task-abc12345-1111-2222-3333-444444444444", + seq: 1, + projectId: "proj-1", + title: "Test task", + description: "A test task", + status: "in-progress", + baseBranch: "main", + worktreePath: "/tmp/wt", + branchName: "dev3/task-test", + groupId: null, + variantIndex: null, + agentId: null, + configId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeRequest(params: Record): CliRequest { + return { id: "req-1", method: "task.requestCompletion", params }; +} + +function setupTask(task: Task): void { + vi.mocked(data.getProject).mockResolvedValue(makeProject()); + vi.mocked(data.loadTasks).mockResolvedValue([task]); +} + +beforeEach(() => { + vi.clearAllMocks(); + _resetCompletionRequestsForTests(); +}); + +describe("task.requestCompletion", () => { + it("errors when the task is already completed", async () => { + setupTask(makeTask({ status: "completed" })); + + const resp = await handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + expect(resp.ok).toBe(false); + expect(resp.error).toContain("already completed"); + }); + + it("errors when no app window is connected", async () => { + setupTask(makeTask()); + vi.mocked(getPushMessage).mockReturnValue(null); + + const resp = await handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + expect(resp.ok).toBe(false); + expect(resp.error).toContain("No app window is connected"); + }); + + it("pushes agentCompletionRequested and completes the task on approval", async () => { + const task = makeTask({ overview: "Agent overview", userOverview: "User overview wins" }); + setupTask(task); + const pushFn = vi.fn(); + vi.mocked(getPushMessage).mockReturnValue(pushFn); + const completedTask = { ...task, status: "completed" as const }; + vi.mocked(moveTask).mockResolvedValue(completedTask); + + const respPromise = handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + await vi.waitFor(() => expect(pushFn).toHaveBeenCalled()); + + const [event, payload] = pushFn.mock.calls[0] as [ + string, + { requestId: string; taskId: string; projectId: string; taskTitle: string; taskOverview?: string }, + ]; + expect(event).toBe("agentCompletionRequested"); + expect(payload.taskId).toBe(task.id); + expect(payload.projectId).toBe("proj-1"); + expect(payload.taskTitle).toBe("Test task"); + expect(payload.taskOverview).toBe("User overview wins"); + + resolveCompletionRequest(payload.requestId, true); + + const resp = await respPromise; + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ approved: true, task: completedTask }); + expect(moveTask).toHaveBeenCalledWith({ taskId: task.id, projectId: "proj-1", newStatus: "completed" }); + }); + + it("returns approved:false without moving the task when declined", async () => { + setupTask(makeTask()); + const pushFn = vi.fn(); + vi.mocked(getPushMessage).mockReturnValue(pushFn); + + const respPromise = handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + await vi.waitFor(() => expect(pushFn).toHaveBeenCalled()); + + const payload = pushFn.mock.calls[0][1] as { requestId: string }; + resolveCompletionRequest(payload.requestId, false); + + const resp = await respPromise; + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ approved: false }); + expect(moveTask).not.toHaveBeenCalled(); + }); + + it("joins an existing pending request instead of pushing a second dialog", async () => { + setupTask(makeTask()); + const pushFn = vi.fn(); + vi.mocked(getPushMessage).mockReturnValue(pushFn); + + const first = handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + await vi.waitFor(() => expect(pushFn).toHaveBeenCalledTimes(1)); + const second = handleRequest(makeRequest({ taskId: "task-abc12345", projectId: "proj-1" })); + // Let the second handler reach createCompletionRequest (and join) before resolving. + await new Promise((r) => setTimeout(r, 10)); + expect(pushFn).toHaveBeenCalledTimes(1); + + const payload = pushFn.mock.calls[0][1] as { requestId: string }; + resolveCompletionRequest(payload.requestId, false); + + const [respA, respB] = await Promise.all([first, second]); + expect(respA.data).toEqual({ approved: false }); + expect(respB.data).toEqual({ approved: false }); + expect(pushFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/bun/__tests__/completion-requests.test.ts b/src/bun/__tests__/completion-requests.test.ts new file mode 100644 index 00000000..41207b46 --- /dev/null +++ b/src/bun/__tests__/completion-requests.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { + createCompletionRequest, + resolveCompletionRequest, + _resetCompletionRequestsForTests, +} from "../completion-requests"; + +beforeEach(() => { + _resetCompletionRequestsForTests(); +}); + +describe("createCompletionRequest", () => { + it("creates a new pending request with a unique id", () => { + const a = createCompletionRequest("task-1", "proj-1"); + const b = createCompletionRequest("task-2", "proj-1"); + + expect(a.isNew).toBe(true); + expect(b.isNew).toBe(true); + expect(a.requestId).not.toBe(b.requestId); + }); + + it("joins the existing request for the same task instead of duplicating", () => { + const first = createCompletionRequest("task-1", "proj-1"); + const second = createCompletionRequest("task-1", "proj-1"); + + expect(second.isNew).toBe(false); + expect(second.requestId).toBe(first.requestId); + expect(second.decision).toBe(first.decision); + }); + + it("creates a fresh request after the previous one was resolved", () => { + const first = createCompletionRequest("task-1", "proj-1"); + resolveCompletionRequest(first.requestId, false); + + const second = createCompletionRequest("task-1", "proj-1"); + expect(second.isNew).toBe(true); + expect(second.requestId).not.toBe(first.requestId); + }); +}); + +describe("resolveCompletionRequest", () => { + it("resolves the decision promise with true on approval", async () => { + const { requestId, decision } = createCompletionRequest("task-1", "proj-1"); + + expect(resolveCompletionRequest(requestId, true)).toBe(true); + await expect(decision).resolves.toBe(true); + }); + + it("resolves the decision promise with false on decline", async () => { + const { requestId, decision } = createCompletionRequest("task-1", "proj-1"); + + expect(resolveCompletionRequest(requestId, false)).toBe(true); + await expect(decision).resolves.toBe(false); + }); + + it("returns false for an unknown requestId", () => { + expect(resolveCompletionRequest("nope", true)).toBe(false); + }); + + it("returns false when resolving the same request twice", () => { + const { requestId } = createCompletionRequest("task-1", "proj-1"); + expect(resolveCompletionRequest(requestId, true)).toBe(true); + expect(resolveCompletionRequest(requestId, true)).toBe(false); + }); + + it("resolves every joined waiter with the same decision", async () => { + const first = createCompletionRequest("task-1", "proj-1"); + const second = createCompletionRequest("task-1", "proj-1"); + + resolveCompletionRequest(first.requestId, true); + await expect(first.decision).resolves.toBe(true); + await expect(second.decision).resolves.toBe(true); + }); +}); diff --git a/src/bun/agent-skills.ts b/src/bun/agent-skills.ts index baba8a00..051900a9 100644 --- a/src/bun/agent-skills.ts +++ b/src/bun/agent-skills.ts @@ -99,6 +99,18 @@ If the project defines custom columns (visible in \`dev3 current\` output), you Each custom column has an 8-char ID prefix and a description of when to use it. `; +const SKILL_COMPLETION_REQUEST = ` +### Completing a task (user approval required) + +\`dev3 task move --status completed\` does NOT complete the task directly. It shows an approval dialog to the user in the app and **blocks for up to 10 minutes** waiting for their answer: + +- **Approved** → the task moves to Completed and this worktree + terminal session are destroyed immediately. +- **Declined** → exit code 6; the task keeps its status and the session stays alive. Continue working or ask the user what to change. +- **Timeout** → the dialog may still be open; if the user approves later, the task completes and the session is destroyed. + +Only request completion when the work is truly done (committed, tested, nothing pending). \`cancelled\` remains fully forbidden via CLI. +`; + const SKILL_NOTES = ` ## Notes (per-task scratchpad) @@ -211,7 +223,7 @@ const SKILL_STATUS_MANUAL = ` ### Rules: - If \`task move\` fails because the task is already in the target status, that is OK — just continue. -${SKILL_CUSTOM_COLUMNS}`; +${SKILL_CUSTOM_COLUMNS}${SKILL_COMPLETION_REQUEST}`; // Simplified status management — for Claude Code (hooks handle everything automatically) const SKILL_STATUS_HOOKS = ` @@ -219,7 +231,7 @@ const SKILL_STATUS_HOOKS = ` Hooks automatically manage task status transitions (\`in-progress\`, \`user-questions\`, \`review-by-ai\`, \`review-by-user\`). Do NOT call \`dev3 task move\` for status changes — hooks handle it. On projects with Automatic AI Review enabled, completed work passes through \`review-by-ai\` before \`review-by-user\`. You can still use \`dev3 task move\` for custom columns. -${SKILL_CUSTOM_COLUMNS}`; +${SKILL_CUSTOM_COLUMNS}${SKILL_COMPLETION_REQUEST}`; // Codex also uses hooks, but the session must be restarted after config changes. const SKILL_STATUS_CODEX_HOOKS = ` @@ -227,7 +239,7 @@ const SKILL_STATUS_CODEX_HOOKS = ` Hooks automatically manage task status transitions (\`in-progress\`, \`review-by-ai\`, \`review-by-user\`) for Codex sessions started after the dev3 config was installed. Do NOT call \`dev3 task move\` for normal active/review transitions when hooks are active. If you need user input or clarification, move the task to \`user-questions\` before your final response. If you are in an older Codex session where hooks clearly are not firing yet, fall back to manual status management: move to \`in-progress\` when you start, \`user-questions\` when blocked, and \`review-by-user\` when finished. -${SKILL_CUSTOM_COLUMNS}`; +${SKILL_CUSTOM_COLUMNS}${SKILL_COMPLETION_REQUEST}`; const SKILL_CODEX_SHELL = ` ## Codex shell note diff --git a/src/bun/cli-socket-server.ts b/src/bun/cli-socket-server.ts index 2a7a5c44..5c4afe4e 100644 --- a/src/bun/cli-socket-server.ts +++ b/src/bun/cli-socket-server.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, unlinkSync, mkdirSync } from "node:fs"; import type { CliRequest, CliResponse, CustomColumn, Label, Project, Task, TaskStatus, TaskNote, NoteSource } from "../shared/types"; -import { ALL_STATUSES, DEV3_REPO_CONFIG_KEYS, LABEL_COLORS, getAllowedTransitions, titleFromDescription } from "../shared/types"; +import { ALL_STATUSES, DEV3_REPO_CONFIG_KEYS, LABEL_COLORS, getAllowedTransitions, getTaskTitle, titleFromDescription } from "../shared/types"; +import { createCompletionRequest } from "./completion-requests"; import * as data from "./data"; import { isActive, activateTask, getPushMessage, getPushMessageLocal, moveTask, triggerColumnAgentIfNeeded, notifyWatchedTaskStatusChange } from "./rpc-handlers"; import { getDevServerStatus, runDevServer, stopDevServer, restartDevServer } from "./rpc-handlers/tmux-pty"; @@ -565,6 +566,41 @@ const handlers: Record = { return updated; }, + // Agent-initiated request to complete a task. Blocks until the user + // approves or declines in the app UI. Approval executes the move even if + // the requesting CLI has already disconnected (its tmux session may have + // hit a client-side timeout while the dialog stayed open). + "task.requestCompletion": async (params) => { + const { project, task } = await resolveTaskFromParams(params); + if (task.status === "completed" || task.status === "cancelled") { + throw new Error(`Task is already ${task.status}`); + } + const push = getPushMessage(); + if (!push) { + throw new Error("No app window is connected — cannot ask the user for approval"); + } + + const { requestId, decision, isNew } = createCompletionRequest(task.id, project.id); + if (isNew) { + // User-edited overview overrides the agent-written one, same as in cards. + const overview = task.userOverview?.trim() || task.overview?.trim() || undefined; + push("agentCompletionRequested", { + requestId, + taskId: task.id, + projectId: project.id, + taskTitle: getTaskTitle(task), + taskOverview: overview, + }); + } + + const approved = await decision; + if (!approved) { + return { approved: false }; + } + const updated = await moveTask({ taskId: task.id, projectId: project.id, newStatus: "completed" }); + return { approved: true, task: updated }; + }, + "devServer.start": async (params) => { const { project, task } = await resolveTaskFromParams(params); return runDevServer({ taskId: task.id, projectId: project.id }); diff --git a/src/bun/completion-requests.ts b/src/bun/completion-requests.ts new file mode 100644 index 00000000..2b85bbb8 --- /dev/null +++ b/src/bun/completion-requests.ts @@ -0,0 +1,65 @@ +import { createLogger } from "./logger"; + +const log = createLogger("completion-requests"); + +interface PendingCompletionRequest { + requestId: string; + taskId: string; + projectId: string; + decision: Promise; + resolve: (approved: boolean) => void; +} + +const pendingByRequestId = new Map(); +const requestIdByTaskId = new Map(); + +/** + * Register (or join) a pending agent-initiated completion request for a task. + * A second request for the same task joins the existing decision promise + * instead of spawning a duplicate dialog — agents may retry after their own + * tool timeout while the user still has the original dialog open. + */ +export function createCompletionRequest( + taskId: string, + projectId: string, +): { requestId: string; decision: Promise; isNew: boolean } { + const existingId = requestIdByTaskId.get(taskId); + if (existingId) { + const existing = pendingByRequestId.get(existingId); + if (existing) { + log.info("Joining existing completion request", { taskId: taskId.slice(0, 8), requestId: existingId }); + return { requestId: existingId, decision: existing.decision, isNew: false }; + } + } + + const requestId = crypto.randomUUID(); + let resolve!: (approved: boolean) => void; + const decision = new Promise((r) => { + resolve = r; + }); + + const entry: PendingCompletionRequest = { requestId, taskId, projectId, decision, resolve }; + pendingByRequestId.set(requestId, entry); + requestIdByTaskId.set(taskId, requestId); + log.info("Created completion request", { taskId: taskId.slice(0, 8), requestId }); + return { requestId, decision, isNew: true }; +} + +/** Resolve a pending request with the user's decision. Returns false if the request is unknown/expired. */ +export function resolveCompletionRequest(requestId: string, approved: boolean): boolean { + const entry = pendingByRequestId.get(requestId); + if (!entry) { + log.debug("resolveCompletionRequest: unknown requestId", { requestId }); + return false; + } + pendingByRequestId.delete(requestId); + requestIdByTaskId.delete(entry.taskId); + entry.resolve(approved); + log.info("Completion request resolved", { taskId: entry.taskId.slice(0, 8), requestId, approved }); + return true; +} + +export function _resetCompletionRequestsForTests(): void { + pendingByRequestId.clear(); + requestIdByTaskId.clear(); +} diff --git a/src/bun/rpc-handlers/task-lifecycle.ts b/src/bun/rpc-handlers/task-lifecycle.ts index ca519337..66ea4dda 100644 --- a/src/bun/rpc-handlers/task-lifecycle.ts +++ b/src/bun/rpc-handlers/task-lifecycle.ts @@ -7,6 +7,7 @@ import * as pty from "../pty-server"; import * as portPool from "../port-pool"; import * as repoConfig from "../repo-config"; import { clonePaths } from "../cow-clone"; +import { resolveCompletionRequest } from "../completion-requests"; import { DEV3_HOME } from "../paths"; import { assertTaskPreparationActive, @@ -1047,6 +1048,13 @@ async function toggleTaskWatch(params: { taskId: string; projectId: string; watc return updated; } +async function respondToAgentCompletionRequest(params: { requestId: string; approved: boolean }): Promise { + const known = resolveCompletionRequest(params.requestId, params.approved); + if (!known) { + log.debug("respondToAgentCompletionRequest: request expired or unknown", { requestId: params.requestId }); + } +} + export const taskLifecycleHandlers = { getTasks, getAllProjectTasks, @@ -1062,4 +1070,5 @@ export const taskLifecycleHandlers = { setUserOverview, clearUserOverview, toggleTaskWatch, + respondToAgentCompletionRequest, }; diff --git a/src/cli/__tests__/task.test.ts b/src/cli/__tests__/task.test.ts index 485c52fd..2ed9277e 100644 --- a/src/cli/__tests__/task.test.ts +++ b/src/cli/__tests__/task.test.ts @@ -421,15 +421,6 @@ describe("task move", () => { ).rejects.toThrow("EXIT_3"); }); - it("blocks completed status (destroys worktree)", async () => { - await expect( - handleTask("move", args(["aaaaaaaa"], { status: "completed" }), SOCKET, null), - ).rejects.toThrow("EXIT_1"); - expect(stderrOutput).toContain("Cannot move to"); - expect(stderrOutput).toContain("destroys the worktree"); - expect(mockSend).not.toHaveBeenCalled(); - }); - it("blocks cancelled status (destroys worktree)", async () => { await expect( handleTask("move", args(["aaaaaaaa"], { status: "cancelled" }), SOCKET, null), @@ -475,6 +466,69 @@ describe("task move", () => { }); }); +// ─── task move --status completed (agent completion request) ──────────────── +// "completed" is not a direct move: the CLI sends task.requestCompletion and +// blocks until the user answers the approval dialog in the app. + +describe("task move --status completed", () => { + it("sends task.requestCompletion with the long approval timeout", async () => { + mockSend.mockResolvedValue(okResp({ approved: true, task: { ...FAKE_TASK, status: "completed" } })); + + await handleTask("move", args(["aaaaaaaa"], { status: "completed" }), SOCKET, null); + + expect(mockSend).toHaveBeenCalledWith( + SOCKET, + "task.requestCompletion", + { taskId: "aaaaaaaa" }, + { timeoutMs: 10 * 60 * 1000 }, + ); + expect(stderrOutput).toContain("requires user approval"); + expect(stdoutOutput).toContain("User approved"); + expect(stdoutOutput).toContain("moved to Completed"); + }); + + it("exits with code 6 when the user declines", async () => { + mockSend.mockResolvedValue(okResp({ approved: false })); + + await expect( + handleTask("move", args(["aaaaaaaa"], { status: "completed" }), SOCKET, null), + ).rejects.toThrow("EXIT_6"); + expect(stderrOutput).toContain("User declined the completion request"); + expect(stderrOutput).toContain("session stays alive"); + }); + + it("exits with a hint when the approval wait times out", async () => { + mockSend.mockRejectedValue(new Error("Socket timeout (600s)")); + + await expect( + handleTask("move", args(["aaaaaaaa"], { status: "completed" }), SOCKET, null), + ).rejects.toThrow("EXIT_1"); + expect(stderrOutput).toContain("Timed out waiting for the user's decision"); + }); + + it("exits on server error", async () => { + mockSend.mockResolvedValue(errResp("Task is already completed")); + + await expect( + handleTask("move", args(["aaaaaaaa"], { status: "completed" }), SOCKET, null), + ).rejects.toThrow("EXIT_1"); + expect(stderrOutput).toContain("Task is already completed"); + }); + + it("prints minimal JSON stdout for Codex stop hooks on approval", async () => { + mockSend.mockResolvedValue(okResp({ approved: true, task: { ...FAKE_TASK, status: "completed" } })); + + await handleTask( + "move", + args(["aaaaaaaa"], { status: "completed", "codex-stop-hook": "true" }), + SOCKET, + null, + ); + + expect(stdoutOutput).toBe("{}"); + }); +}); + // ─── --id flag support ─────────────────────────────────────────────────────── // The CLI should accept --id as an alternative to positional arg. // This lets agents update/show/move ANY task, not just the current one. @@ -773,8 +827,9 @@ describe("task create --description", () => { }); // ─── task move: status validation ──────────────────────────────────────────── -// The CLI blocks "completed" and "cancelled" only. Unknown values (potential -// custom column IDs) are forwarded to the server, which validates them. +// The CLI blocks "cancelled" only; "completed" becomes an approval request. +// Unknown values (potential custom column IDs) are forwarded to the server, +// which validates them. describe("task move status validation", () => { it("forwards unknown status values to the server (may be a custom column ID)", async () => { diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index bd975bf7..5fb2081a 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -1,5 +1,6 @@ -import type { Task, TaskStatus } from "../../shared/types"; +import type { CliResponse, Task, TaskStatus } from "../../shared/types"; import { STATUS_LABELS, ALL_STATUSES, getTaskTitle } from "../../shared/types"; +import { CLI_EXIT_CODE_COMPLETION_DECLINED } from "../../shared/cli-exit-codes"; import { CODEX_STOP_HOOK_FLAG, CODEX_STOP_HOOK_SUCCESS_JSON } from "../../shared/agent-hooks"; import { sendRequest } from "../socket-client"; import { printDetail, exitError, exitUsage } from "../output"; @@ -7,11 +8,16 @@ import type { ParsedArgs } from "../args"; import { expandShortId, resolveProjectId, type CliContext } from "../context"; import { rejectUnknownFlags } from "../flag-validation"; -// Statuses that destroy the worktree + terminal are forbidden via CLI. -// An agent running inside a worktree must not be able to kill its own session. +// Statuses that destroy the worktree + terminal are not directly reachable via +// CLI. `completed` is special-cased: it becomes a blocking approval request the +// user answers in the app. `cancelled` stays fully forbidden — an agent must +// not be able to silently kill its own session. const DESTRUCTIVE_STATUSES: TaskStatus[] = ["completed", "cancelled"]; const CLI_ALLOWED_STATUSES = ALL_STATUSES.filter((s) => !DESTRUCTIVE_STATUSES.includes(s)); +// How long the CLI waits for the user to answer the approval dialog. +const COMPLETION_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000; + function formatDate(iso: string): string { const d = new Date(iso); return d.toLocaleDateString("en-GB", { @@ -161,6 +167,57 @@ async function updateTask(args: ParsedArgs, socketPath: string, context: CliCont process.stdout.write(`Updated task ${task.id.slice(0, 8)}: ${getTaskTitle(task)}\n`); } +async function requestCompletion( + taskId: string, + args: ParsedArgs, + socketPath: string, + context: CliContext | null, + codexStopHook: boolean, +): Promise { + const params: Record = { taskId }; + const projectId = resolveProjectId(args.flags.project, context); + if (projectId) params.projectId = projectId; + + process.stderr.write( + "Completing a task destroys its worktree and terminal session, so it requires user approval.\n" + + "Waiting for the user to respond in the dev-3.0 app (up to 10 minutes)...\n", + ); + + let resp: CliResponse; + try { + resp = await sendRequest(socketPath, "task.requestCompletion", params, { + timeoutMs: COMPLETION_APPROVAL_TIMEOUT_MS, + }); + } catch (err) { + if (err instanceof Error && err.message.startsWith("Socket timeout")) { + exitError( + "Timed out waiting for the user's decision", + "The approval dialog may still be open in the app — if the user approves later, the task will complete and this session will be destroyed.", + ); + } + throw err; + } + if (!resp.ok) exitError(resp.error || "Failed to request task completion"); + + const result = resp.data as { approved: boolean; task?: Task }; + if (!result.approved) { + exitError( + "User declined the completion request", + "The task keeps its current status and this session stays alive.\nContinue working or ask the user what they want to change before completing.", + CLI_EXIT_CODE_COMPLETION_DECLINED, + ); + } + + if (codexStopHook) { + process.stdout.write(CODEX_STOP_HOOK_SUCCESS_JSON); + return; + } + process.stdout.write( + `User approved — task ${(result.task?.id ?? taskId).slice(0, 8)} moved to Completed.\n` + + "This worktree and terminal session are being destroyed now.\n", + ); +} + async function moveTask(args: ParsedArgs, socketPath: string, context: CliContext | null): Promise { rejectUnknownFlags(args, ["id", "task", "task-id", "project", "status", "if-status", "if-status-not", CODEX_STOP_HOOK_FLAG.slice(2)]); const taskId = resolveTaskId(args, context); @@ -170,12 +227,12 @@ async function moveTask(args: ParsedArgs, socketPath: string, context: CliContex const newStatus = args.flags.status; if (!newStatus) { - exitUsage(`--status is required. Valid built-in: ${CLI_ALLOWED_STATUSES.join(", ")}; or a custom column ID (see \`dev3 current\`)`); + exitUsage(`--status is required. Valid built-in: ${CLI_ALLOWED_STATUSES.join(", ")}; \`completed\` (asks the user for approval); or a custom column ID (see \`dev3 current\`)`); } - if (DESTRUCTIVE_STATUSES.includes(newStatus as TaskStatus)) { + if (newStatus === "cancelled") { exitError( - `Cannot move to "${newStatus}" via CLI`, - `This status destroys the worktree and terminal session.\nUse the desktop app UI to mark tasks as ${newStatus}.`, + `Cannot move to "cancelled" via CLI`, + `This status destroys the worktree and terminal session.\nUse the desktop app UI to mark tasks as cancelled.`, ); } // Non-built-in values may be custom column IDs — let the server validate @@ -184,6 +241,12 @@ async function moveTask(args: ParsedArgs, socketPath: string, context: CliContex const ifStatusNot = args.flags["if-status-not"]; const codexStopHook = args.flags[CODEX_STOP_HOOK_FLAG.slice(2)] === "true"; + // `completed` is not a direct move — it asks the user for approval in the + // app and blocks until they answer (or the wait times out). + if (newStatus === "completed") { + return requestCompletion(taskId, args, socketPath, context, codexStopHook); + } + const params: Record = { taskId, newStatus }; if (ifStatus) params.ifStatus = ifStatus; if (ifStatusNot) params.ifStatusNot = ifStatusNot; diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts index 5b797b81..b081c365 100644 --- a/src/cli/socket-client.ts +++ b/src/cli/socket-client.ts @@ -1,10 +1,13 @@ import { connect } from "node:net"; import type { CliRequest, CliResponse } from "../shared/types"; +const DEFAULT_TIMEOUT_MS = 30_000; + export async function sendRequest( socketPath: string, method: string, params: Record = {}, + opts: { timeoutMs?: number } = {}, ): Promise { const req: CliRequest = { id: crypto.randomUUID(), @@ -50,9 +53,10 @@ export async function sendRequest( } }); - socket.setTimeout(30_000, () => { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + socket.setTimeout(timeoutMs, () => { socket.destroy(); - reject(new Error("Socket timeout (30s)")); + reject(new Error(`Socket timeout (${Math.round(timeoutMs / 1000)}s)`)); }); }); } diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index b14124b1..a5369ef7 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -522,6 +522,49 @@ function App() { return () => window.removeEventListener("rpc:branchMerged", onBranchMerged); }, [dispatch, navigate, t]); + // Listen for agent-initiated completion requests — the CLI is blocked on a + // socket waiting for the user's decision, so always respond, even on cancel. + useEffect(() => { + async function onAgentCompletionRequested(e: Event) { + const { requestId, taskId, projectId, taskTitle, taskOverview } = (e as CustomEvent).detail as { + requestId: string; + taskId: string; + projectId: string; + taskTitle: string; + taskOverview?: string; + }; + let approved = false; + try { + approved = await confirm({ + title: t("app.agentCompletionTitle"), + message: t("app.agentCompletionMessage"), + info: { title: taskTitle, body: taskOverview }, + confirmLabel: t("app.agentCompletionConfirm"), + cancelLabel: t("app.agentCompletionCancel"), + danger: true, + agentInitiated: true, + }); + } catch (err) { + console.error("[App] confirm (agent-completion) failed:", err); + } + if (approved) { + // Leave the task's full-screen terminal BEFORE the worktree is + // destroyed (same reasoning as the branch-merged flow above). + const currentRoute = routeRef.current; + if (currentRoute.screen === "task" && currentRoute.taskId === taskId) { + navigate({ screen: "project", projectId }); + } + dispatch({ type: "clearBell", taskId }); + trackEvent("task_moved", { to_status: "completed", agent_requested: true }); + } + api.request.respondToAgentCompletionRequest({ requestId, approved }).catch((err) => + console.error("respondToAgentCompletionRequest failed:", err), + ); + } + window.addEventListener("rpc:agentCompletionRequested", onAgentCompletionRequested); + return () => window.removeEventListener("rpc:agentCompletionRequested", onAgentCompletionRequested); + }, [dispatch, navigate, t]); + // Listen for silent update ready notification useEffect(() => { function onUpdateAvailable(e: Event) { diff --git a/src/mainview/__tests__/App.test.tsx b/src/mainview/__tests__/App.test.tsx index 9c23b61e..c6875cdc 100644 --- a/src/mainview/__tests__/App.test.tsx +++ b/src/mainview/__tests__/App.test.tsx @@ -29,6 +29,7 @@ vi.mock("../rpc", () => ({ }), moveTask: vi.fn().mockResolvedValue({}), dismissMergeCompletionPrompt: vi.fn().mockResolvedValue(undefined), + respondToAgentCompletionRequest: vi.fn().mockResolvedValue(undefined), }, }, })); @@ -782,4 +783,66 @@ describe("App keyboard shortcuts", () => { expect(screen.queryByTestId("project-screen")).not.toBeInTheDocument(); }); }); + + describe("agent completion request dialog", () => { + const fireAgentCompletionRequested = (requestId: string, taskId: string, projectId: string) => + act(async () => { + window.dispatchEvent( + new CustomEvent("rpc:agentCompletionRequested", { + detail: { requestId, taskId, projectId, taskTitle: "Some task" }, + }), + ); + }); + + it("responds with approved:true and navigates away from the doomed task screen", async () => { + vi.mocked(api.request.getProjects).mockResolvedValue([ + { id: "p1", name: "Alpha", path: "/a", setupScript: "", devScript: "", cleanupScript: "", defaultBaseBranch: "main", createdAt: "" }, + ]); + vi.mocked(api.request.getUpdateRoute).mockResolvedValue({ + route: JSON.stringify({ screen: "task", projectId: "p1", taskId: "t1" }), + }); + vi.mocked(confirm).mockResolvedValue(true); + + await renderApp(); + expect(screen.getByTestId("task-screen")).toBeInTheDocument(); + + await fireAgentCompletionRequested("req-1", "t1", "p1"); + + await waitFor(() => { + expect(api.request.respondToAgentCompletionRequest).toHaveBeenCalledWith({ + requestId: "req-1", + approved: true, + }); + }); + expect(screen.getByTestId("project-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("task-screen")).not.toBeInTheDocument(); + // The move itself happens in the bun process, not the renderer. + expect(api.request.moveTask).not.toHaveBeenCalled(); + expect(vi.mocked(confirm).mock.calls[0][0]).toMatchObject({ agentInitiated: true, danger: true }); + }); + + it("responds with approved:false and stays in place when declined", async () => { + vi.mocked(api.request.getProjects).mockResolvedValue([ + { id: "p1", name: "Alpha", path: "/a", setupScript: "", devScript: "", cleanupScript: "", defaultBaseBranch: "main", createdAt: "" }, + ]); + vi.mocked(api.request.getUpdateRoute).mockResolvedValue({ + route: JSON.stringify({ screen: "task", projectId: "p1", taskId: "t1" }), + }); + vi.mocked(confirm).mockResolvedValue(false); + + await renderApp(); + expect(screen.getByTestId("task-screen")).toBeInTheDocument(); + + await fireAgentCompletionRequested("req-2", "t1", "p1"); + + await waitFor(() => { + expect(api.request.respondToAgentCompletionRequest).toHaveBeenCalledWith({ + requestId: "req-2", + approved: false, + }); + }); + expect(screen.getByTestId("task-screen")).toBeInTheDocument(); + expect(api.request.moveTask).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/mainview/__tests__/confirm.test.tsx b/src/mainview/__tests__/confirm.test.tsx index 18b15b97..96602800 100644 --- a/src/mainview/__tests__/confirm.test.tsx +++ b/src/mainview/__tests__/confirm.test.tsx @@ -61,6 +61,58 @@ describe("confirm service", () => { expect(screen.getByRole("button", { name: "No" })).toBeInTheDocument(); }); + it("shows the AI agent badge for agent-initiated confirms", async () => { + renderHost(); + act(() => { + void confirm({ title: "Agent asks", message: "M", agentInitiated: true }); + }); + + expect(await screen.findByText("AI agent request")).toBeInTheDocument(); + }); + + it("does not show the AI agent badge for regular confirms", async () => { + renderHost(); + act(() => { + void confirm({ title: "Plain", message: "M" }); + }); + + await screen.findByText("Plain"); + expect(screen.queryByText("AI agent request")).not.toBeInTheDocument(); + }); + + it("focuses the cancel button for agent-initiated confirms", async () => { + renderHost(); + act(() => { + void confirm({ title: "Agent asks", message: "M", agentInitiated: true, cancelLabel: "Keep session" }); + }); + + const cancelBtn = await screen.findByRole("button", { name: "Keep session" }); + expect(cancelBtn).toHaveFocus(); + }); + + it("renders the info subject card with title and body", async () => { + renderHost(); + act(() => { + void confirm({ + title: "Agent asks", + message: "M", + info: { title: "My important task", body: "Implementing the thing; almost done." }, + }); + }); + + expect(await screen.findByText("My important task")).toBeInTheDocument(); + expect(screen.getByText("Implementing the thing; almost done.")).toBeInTheDocument(); + }); + + it("renders the info card without a body when body is omitted", async () => { + renderHost(); + act(() => { + void confirm({ title: "Agent asks", message: "M", info: { title: "Title only" } }); + }); + + expect(await screen.findByText("Title only")).toBeInTheDocument(); + }); + it("resolves false when no host is mounted", async () => { // No ConfirmHost rendered → fail-closed. await expect(confirm({ title: "T", message: "M" })).resolves.toBe(false); diff --git a/src/mainview/confirm.tsx b/src/mainview/confirm.tsx index 00610b03..488d95da 100644 --- a/src/mainview/confirm.tsx +++ b/src/mainview/confirm.tsx @@ -8,6 +8,19 @@ export interface ConfirmOptions { cancelLabel?: string; /** Style the confirm button as destructive (red). */ danger?: boolean; + /** + * Mark the dialog as initiated by an AI agent, not by the user's own click: + * shows a robot badge, an accent border, and autofocuses Cancel so muscle + * memory cannot accidentally approve a session-destroying request. + */ + agentInitiated?: boolean; + /** + * Optional highlighted subject card rendered between the title and the + * message — an accent-tinted panel with a prominent title (e.g. the task + * name) and an optional secondary line (e.g. the task overview). Use it + * when the dialog is *about* a specific object the user must recognize. + */ + info?: { title: string; body?: string }; } interface PendingConfirm extends ConfirmOptions { @@ -67,12 +80,51 @@ export function ConfirmHost() { if (e.target === e.currentTarget) close(false); }} > -
+
+ {pending.agentInitiated && ( +
+ + {"\u{F06A9}"} + + {t("confirmDialog.agentBadge")} +
+ )}

{pending.title}

+ {pending.info && ( +
+
+ + {"\u{F0AE2}"} + + {/* `text-base` is unusable here: the project defines a `base` color + token, so Tailwind also emits text-base as a COLOR utility that + overrides text-accent. Use an arbitrary font-size instead. */} +
+ {pending.info.title} +
+
+ {pending.info.body && ( +
+ {pending.info.body} +
+ )} +
+ )}

{pending.message}