diff --git a/.gitleaksignore b/.gitleaksignore index 717cd7f17..72f2d4162 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -22,3 +22,8 @@ # gitleaks' generic-api-key rule fired only because the const identifier ends in # `KEY` and the namespaced value has mild entropy. Scoped to its original commit. 86e23824a491bc8de59697b62169783f917e63b1:apps/desktop/src/renderer/lib/bannerDismiss.ts:generic-api-key:24 + +# Synthetic filename used to prove files under `.ade/secrets` cannot be +# imported as proof artifacts. No credential value is present; keep the +# exception scoped to the original PR commit and exact test finding. +631f4066397cd4f98b095d3f7d3a43d0cf758805:apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts:generic-api-key:423 diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index f80234d6b..bb5f88868 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -444,6 +444,13 @@ ade code ade code --embedded ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id +ade proof attach shots/result.png --caption "Checkout complete" +ade proof rm artifact-id +ade proof broken --text # list missing/unimported proof records +ade proof recover artifact-id # re-import when the original capture still exists +ade proof prune # preview broken records; does not delete +ade proof prune --broken # delete every broken proof record +ade proof actions --text # full computer_use_artifacts action inventory ade ios-sim devices --text ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 378146507..d29a83167 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1267,6 +1267,234 @@ describe("adeRpcServer", () => { }); }); + // `ade/actions/call` is the only way into `runTool`, and it dispatches off + // READ_ONLY_TOOLS / MUTATION_TOOLS. A tool registered in the inventory but + // absent from both sets is advertised and unreachable — which is how the + // whole proof delete surface (`ade proof rm|prune|recover`) shipped broken. + it("dispatches every registered computer-use mutation tool", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "chat-1", role: "agent" }); + + for (const name of [ + "delete_computer_use_artifacts", + "prune_broken_computer_use_artifacts", + "recover_computer_use_artifact", + "list_broken_computer_use_artifacts", + ]) { + const result = await callTool(handler, name, {}); + const serialized = JSON.stringify(result ?? {}); + expect(serialized).not.toContain(`Unsupported ADE action: ${name}`); + } + }); + + it("scopes proof lifecycle tools to the authenticated chat and lane owners", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-1", laneId: "lane-1" } as any); + const owned = { + id: "owned-proof", + laneId: "lane-1", + links: [], + }; + const foreign = { + id: "foreign-proof", + links: [{ ownerKind: "chat_session", ownerId: "chat-2" }], + }; + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockImplementation((args: any) => { + if (args.artifactId === owned.id) return [owned]; + if (args.artifactId === foreign.id) return [foreign]; + if (args.ownerKind === "chat_session" && args.ownerId === "chat-1") return [owned]; + return []; + }); + fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts = vi.fn(() => ({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + })); + fixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn(() => [ + { artifactId: owned.id }, + { artifactId: foreign.id }, + ]); + fixture.runtime.computerUseArtifactBrokerService.recoverArtifact = vi.fn(); + await initialize(handler, { + callerId: "chat-1", + role: "agent", + chatSessionId: "chat-1", + }); + + const foreignOwnerIngest = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + inputs: [{ kind: "screenshot", title: "Foreign owner", path: "proof.png" }], + owners: [{ kind: "chat_session", id: "chat-2" }], + }); + expect(foreignOwnerIngest.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); + + await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + inputs: [{ kind: "screenshot", title: "Published proof", path: "proof.png" }], + owners: [{ kind: "github_pr", id: "https://github.com/arul28/ADE/pull/933" }], + }); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledTimes(1); + + const listed = await callTool(handler, "list_computer_use_artifacts", {}); + expect(listed.structuredContent.artifacts).toEqual([owned]); + + const foreignList = await callTool(handler, "list_computer_use_artifacts", { + ownerKind: "chat_session", + ownerId: "chat-2", + }); + expect(foreignList.isError).toBe(true); + + const foreignDelete = await callTool(handler, "delete_computer_use_artifacts", { + artifactId: foreign.id, + }); + expect(foreignDelete.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).not.toHaveBeenCalled(); + + await callTool(handler, "delete_computer_use_artifacts", { artifactId: owned.id }); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: [owned.id], + }); + + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockClear(); + const broken = await callTool(handler, "list_broken_computer_use_artifacts", { limit: 10 }); + expect(broken.structuredContent.broken).toEqual([{ artifactId: owned.id }]); + expect(fixture.runtime.computerUseArtifactBrokerService.listArtifacts).toHaveBeenCalledTimes(2); + + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockClear(); + fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts.mockClear(); + await callTool(handler, "prune_broken_computer_use_artifacts", {}); + expect(fixture.runtime.computerUseArtifactBrokerService.listArtifacts).toHaveBeenCalledTimes(2); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: [owned.id], + }); + + const foreignRecover = await callTool(handler, "recover_computer_use_artifact", { + artifactId: foreign.id, + }); + expect(foreignRecover.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.recoverArtifact).not.toHaveBeenCalled(); + + await callTool(handler, "recover_computer_use_artifact", { artifactId: owned.id }); + expect(fixture.runtime.computerUseArtifactBrokerService.recoverArtifact).toHaveBeenCalledWith({ + artifactId: owned.id, + }); + }); + + it("gives standalone ade CLI callers project-wide proof lifecycle scope without widening unbound agents", async () => { + const standaloneFixture = createRuntime(); + const standaloneHandler = createAdeRpcRequestHandler({ + runtime: standaloneFixture.runtime, + serverVersion: "test", + }); + const artifacts = [ + { id: "proof-1", createdAt: "2026-07-29T01:00:00.000Z", laneId: "lane-1", links: [] }, + { id: "proof-2", createdAt: "2026-07-29T02:00:00.000Z", laneId: "lane-2", links: [] }, + ]; + standaloneFixture.runtime.computerUseArtifactBrokerService.listArtifacts = vi.fn((args: any) => + args.artifactId ? artifacts.filter((artifact) => artifact.id === args.artifactId) : artifacts + ); + standaloneFixture.runtime.computerUseArtifactBrokerService.deleteArtifacts = vi.fn(() => ({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + })); + standaloneFixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn((args: any) => [ + { artifactId: "proof-1" }, + { artifactId: "proof-2" }, + ].slice(0, args.limit)); + standaloneFixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts = vi.fn(() => ({ + deleted: ["proof-1", "proof-2"], + missing: [], + failed: [], + freedBytes: 42, + })); + standaloneFixture.runtime.computerUseArtifactBrokerService.recoverArtifact = vi.fn(() => ({ + recovered: true, + })); + await initialize(standaloneHandler, { + callerId: "ade-cli:4242", + role: "agent", + }); + + const listed = await callTool(standaloneHandler, "list_computer_use_artifacts", {}); + expect(listed.structuredContent.artifacts).toEqual(artifacts); + + await callTool(standaloneHandler, "delete_computer_use_artifacts", { + artifactId: "proof-2", + }); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: ["proof-2"], + }); + + const broken = await callTool(standaloneHandler, "list_broken_computer_use_artifacts", { + limit: 1, + }); + expect(broken.structuredContent.broken).toEqual([{ artifactId: "proof-1" }]); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts).toHaveBeenCalledWith({ + limit: 1, + }); + + await callTool(standaloneHandler, "prune_broken_computer_use_artifacts", {}); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts).toHaveBeenCalledTimes(1); + + await callTool(standaloneHandler, "recover_computer_use_artifact", { + artifactId: "proof-2", + }); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.recoverArtifact).toHaveBeenCalledWith({ + artifactId: "proof-2", + }); + + const unboundAgentFixture = createRuntime(); + const unboundAgentHandler = createAdeRpcRequestHandler({ + runtime: unboundAgentFixture.runtime, + serverVersion: "test", + }); + await initialize(unboundAgentHandler, { + callerId: "unbound-agent", + role: "agent", + }); + + for (const [name, args] of [ + ["list_computer_use_artifacts", {}], + ["delete_computer_use_artifacts", { artifactId: "proof-1" }], + ["list_broken_computer_use_artifacts", {}], + ["prune_broken_computer_use_artifacts", {}], + ["recover_computer_use_artifact", { artifactId: "proof-1" }], + ] as const) { + const denied = await callTool(unboundAgentHandler, name, args); + expect(denied.isError).toBe(true); + } + }); + + it("sorts the scoped proof union before applying its limit", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-1", laneId: "lane-1" } as any); + const older = { id: "older", createdAt: "2026-07-29T01:00:00.000Z" }; + const newer = { id: "newer", createdAt: "2026-07-29T02:00:00.000Z" }; + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockImplementation((args: any) => { + if (args.ownerKind === "chat_session") return [older]; + if (args.ownerKind === "lane") return [newer]; + return []; + }); + await initialize(handler, { + callerId: "chat-1", + role: "agent", + chatSessionId: "chat-1", + }); + + const listed = await callTool(handler, "list_computer_use_artifacts", { limit: 1 }); + + expect(listed.structuredContent.artifacts).toEqual([newer]); + }); + it("caps a session-bound CTO caller and scopes lifecycle actions to its own session", async () => { await withEnv({ ADE_DEFAULT_ROLE: "cto", ADE_CHAT_SESSION_ID: undefined }, async () => { const { runtime } = createRuntime(); @@ -1738,10 +1966,14 @@ describe("adeRpcServer", () => { it("auto-links computer-use ingestion to standalone chat sessions", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + const laneRoot = runtime.laneService.getLaneWorktreePath("lane-1"); + fs.mkdirSync(laneRoot, { recursive: true }); + runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); await initialize(handler, { callerId: "chat-session-1", role: "agent", + chatSessionId: "chat-session-1", }); await callTool(handler, "ingest_computer_use_artifacts", { @@ -1751,7 +1983,7 @@ describe("adeRpcServer", () => { { kind: "screenshot", title: "Chat proof", - path: "/tmp/chat-proof.png", + path: path.join(laneRoot, "chat-proof.png"), }, ], }); @@ -1772,26 +2004,143 @@ describe("adeRpcServer", () => { ); }); - it("rejects computer-use manifests outside the project root", async () => { + it("forwards the caller's root so relative capture paths resolve in the agent's lane worktree", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - const outsideManifest = path.join(path.dirname(fixture.runtime.projectRoot), `ade-artifacts-${Date.now()}.json`); - fs.writeFileSync(outsideManifest, JSON.stringify([{ kind: "screenshot", path: "/tmp/shot.png" }]), "utf8"); + const laneRoot = fixture.runtime.laneService.getLaneWorktreePath("lane-1"); + fs.mkdirSync(laneRoot, { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); - try { - await initialize(handler, { callerId: "chat-session-1", role: "agent" }); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + callerRoot: laneRoot, + inputs: [{ kind: "screenshot", title: "Lane proof", path: "shots/proof.png" }], + }); + + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ callerRoot: fs.realpathSync(laneRoot) }), + ); + }); + + it("infers the lane scope for standalone ade proof attach calls", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const laneRoot = fixture.runtime.laneService.getLaneWorktreePath("lane-1"); + const callerRoot = path.join(laneRoot, "packages", "app"); + fs.mkdirSync(callerRoot, { recursive: true }); + + await initialize(handler, { + callerId: "ade-cli:4242", + role: "agent", + }); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + callerRoot, + inputs: [{ kind: "screenshot", title: "Standalone proof", path: path.join(callerRoot, "proof.png") }], + }); + + expect(response.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ + callerRoot: fs.realpathSync(callerRoot), + owners: expect.arrayContaining([ + expect.objectContaining({ kind: "lane", id: "lane-1" }), + ]), + }), + ); + }); + + it("rejects a relative caller root, which would resolve differently on each side", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); + + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + callerRoot: "../elsewhere", + inputs: [{ kind: "screenshot", title: "Proof", path: "shots/proof.png" }], + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("absolute"); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); + }); + + it("rejects caller roots and lane ids outside the server-authorized chat lane", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + const lane2Root = fixture.runtime.laneService.getLaneWorktreePath("lane-2"); + fs.mkdirSync(lane2Root, { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + + for (const args of [ + { + callerRoot: lane2Root, + inputs: [{ kind: "screenshot", title: "Wrong root", path: "shots/proof.png" }], + }, + { + laneId: "lane-2", + inputs: [{ kind: "screenshot", title: "Wrong lane", path: "shots/proof.png" }], + }, + { + inputs: [{ kind: "screenshot", title: "Wrong absolute path", path: path.join(lane2Root, "proof.png") }], + }, + ]) { const response = await callTool(handler, "ingest_computer_use_artifacts", { - backendStyle: "external_cli", - backendName: "agent-browser", - manifestPath: `../${path.basename(outsideManifest)}`, + backendStyle: "manual", + backendName: "ade-cli", + ...args, }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("project root"); - expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); - } finally { - fs.rmSync(outsideManifest, { force: true }); } + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); + }); + + it("allows absolute proof paths outside managed worktrees to reach the broker allow-list", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + + const externalPath = path.join(os.tmpdir(), "ade-proof-external.png"); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "external_cli", + backendName: "agent-browser", + inputs: [{ kind: "screenshot", title: "External proof", path: externalPath }], + }); + + expect(response.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: [expect.objectContaining({ path: externalPath })], + }), + ); }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index bb78bf543..2fa0afbad 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -8,7 +8,6 @@ import { getLocalComputerUseCapabilities, toProjectArtifactUri, } from "../../desktop/src/main/services/computerUse/localComputerUse"; -import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPayload } from "../../desktop/src/main/services/proof/agentBrowserArtifactAdapter"; import { ADE_ACTION_DOMAIN_NAMES, type AdeActionDomain, @@ -544,7 +543,7 @@ const TOOL_SPECS: ToolSpec[] = [ backendName: { type: "string", minLength: 1 }, toolName: { type: "string" }, command: { type: "string" }, - manifestPath: { type: "string" }, + callerRoot: { type: "string", description: "Absolute directory that relative input paths are resolved against. Defaults to the agent's workspace root." }, inputs: { type: "array", items: { @@ -620,6 +619,44 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "delete_computer_use_artifacts", + description: "Delete stored proof artifacts: removes the database records and the stored file. Idempotent.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + artifactId: { type: "string", minLength: 1 }, + artifactIds: { type: "array", items: { type: "string", minLength: 1 } }, + } + } + }, + { + name: "list_broken_computer_use_artifacts", + description: "List proof records whose stored file is missing or was never imported, with the path each can be recovered from when one survives.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + limit: { type: "number", minimum: 1, maximum: 2000, default: 200 }, + } + } + }, + { + name: "prune_broken_computer_use_artifacts", + description: "Delete every proof record whose file is missing or was never imported.", + inputSchema: { type: "object", additionalProperties: false, properties: {} } + }, + { + name: "recover_computer_use_artifact", + description: "Re-import a broken proof record's original file when it still exists on disk.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["artifactId"], + properties: { artifactId: { type: "string", minLength: 1 } } + } + }, { name: "get_computer_use_backend_status", description: "Describe external-first computer-use backends available to ADE and the local fallback status.", @@ -1396,12 +1433,16 @@ const READ_ONLY_TOOLS = new Set([ "getLinearIssueComments", "get_environment_info", "list_computer_use_artifacts", + "list_broken_computer_use_artifacts", "get_computer_use_backend_status", ]); const MUTATION_TOOLS = new Set([ "saveMemory", "create_lane", + "delete_computer_use_artifacts", + "prune_broken_computer_use_artifacts", + "recover_computer_use_artifact", "run_ade_action", "start_cli_session", "send_to_session", @@ -2052,6 +2093,171 @@ function resolveLaneWorktreePath(runtime: AdeRuntime, laneId: string | null | un return null; } +function canonicalAuthorizationPath(candidate: string): string { + const resolved = path.resolve(candidate); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +function isPathWithinAuthorizedRoot(root: string, candidate: string): boolean { + try { + resolvePathWithinRoot(root, candidate, { allowMissing: true }); + return true; + } catch { + return false; + } +} + +async function resolveAuthorizedComputerUseIngestRoot( + runtime: AdeRuntime, + session: SessionState, + toolArgs: Record, +): Promise<{ laneId: string | null; root: string; callerRoot: string }> { + const requestedLaneId = asOptionalTrimmedString(toolArgs.laneId); + const sessionLaneId = resolveChatSessionLaneId(runtime, session); + const projectWideAuthorized = session.identity.role === "cto" && isUserClientSession(session); + const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot); + if (callerRoot && !path.isAbsolute(callerRoot)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); + } + if (!projectWideAuthorized && requestedLaneId && requestedLaneId !== sessionLaneId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "laneId must match the caller's authorized chat-session lane", + ); + } + const inferredLane = !requestedLaneId + && !sessionLaneId + && callerRoot + && isUnboundAdeCliCaller(session) + ? (await runtime.laneService.list({ includeArchived: false, includeStatus: false }).catch(() => [])) + .flatMap((lane) => { + const roots = [lane.worktreePath, lane.attachedRootPath] + .map((root) => asOptionalTrimmedString(root)) + .filter((root): root is string => Boolean(root)) + .map((root) => canonicalAuthorizationPath(root)); + return roots + .filter((root) => isPathWithinAuthorizedRoot(root, callerRoot)) + .map((root) => ({ laneId: lane.id, root })); + }) + .sort((left, right) => right.root.length - left.root.length)[0] ?? null + : null; + const authorizedLaneId = requestedLaneId ?? sessionLaneId ?? inferredLane?.laneId ?? null; + const authorizedRoot = authorizedLaneId + ? inferredLane?.root ?? resolveLaneWorktreePath(runtime, authorizedLaneId) + : projectWideAuthorized + ? runtime.projectRoot + : null; + if (!authorizedRoot) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "Computer-use ingestion requires an authorized lane worktree.", + ); + } + if ( + callerRoot + && !isPathWithinAuthorizedRoot(authorizedRoot, callerRoot) + ) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "callerRoot must be inside the server-authorized lane worktree", + ); + } + const canonicalRoot = canonicalAuthorizationPath(authorizedRoot); + return { + laneId: authorizedLaneId, + root: canonicalRoot, + callerRoot: canonicalAuthorizationPath(callerRoot ?? canonicalRoot), + }; +} + +function isProjectWideProofMaintenanceAuthorized(session: SessionState): boolean { + return (session.identity.role === "cto" && isUserClientSession(session)) + || isUnboundAdeCliCaller(session); +} + +function resolveAuthorizedProofOwners( + runtime: AdeRuntime, + session: SessionState, +): ComputerUseArtifactOwner[] { + const owners: ComputerUseArtifactOwner[] = []; + const add = (kind: ComputerUseArtifactOwner["kind"], id: string | null | undefined) => { + const normalizedId = asOptionalTrimmedString(id); + if (!normalizedId) return; + if (owners.some((owner) => owner.kind === kind && owner.id === normalizedId)) return; + owners.push({ kind, id: normalizedId, relation: "attached_to" }); + }; + add("chat_session", session.identity.chatSessionId); + add("lane", resolveChatSessionLaneId(runtime, session)); + add("automation_run", session.identity.runId); + return owners; +} + +function validateComputerUseOwnerClaims( + runtime: AdeRuntime, + session: SessionState, + toolArgs: Record, +): void { + if (isProjectWideProofMaintenanceAuthorized(session)) return; + const authorized = resolveAuthorizedProofOwners(runtime, session); + const assertAuthorized = (kind: string | null, id: string | null) => { + if (!kind || !id) return; + const normalizedKind = kind === "chat" ? "chat_session" : kind === "pr" ? "github_pr" : kind; + // Publishing proof to an explicitly named PR or Linear issue is a + // legitimate cross-surface operation. Local ownership must still come + // from the authenticated session context. + if (normalizedKind === "github_pr" || normalizedKind === "linear_issue") return; + if (!authorized.some((owner) => owner.kind === normalizedKind && owner.id === id)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + "Proof owner claims must match the caller's authenticated chat, lane, or automation run.", + ); + } + }; + assertAuthorized(asOptionalTrimmedString(toolArgs.ownerKind), asOptionalTrimmedString(toolArgs.ownerId)); + assertAuthorized("lane", asOptionalTrimmedString(toolArgs.laneId)); + assertAuthorized("chat_session", asOptionalTrimmedString(toolArgs.chatSessionId)); + assertAuthorized("automation_run", asOptionalTrimmedString(toolArgs.automationRunId)); + for (const entry of Array.isArray(toolArgs.owners) ? toolArgs.owners : []) { + const owner = safeObject(entry); + assertAuthorized(asOptionalTrimmedString(owner.kind), asOptionalTrimmedString(owner.id)); + } +} + +function artifactMatchesAuthorizedOwners( + artifact: { + laneId?: string | null; + links?: Array<{ ownerKind?: string; ownerId?: string }>; + } | null | undefined, + owners: ComputerUseArtifactOwner[], +): boolean { + return Boolean( + owners.some((owner) => owner.kind === "lane" && owner.id === artifact?.laneId) + || artifact?.links?.some((link) => + owners.some((owner) => owner.kind === link.ownerKind && owner.id === link.ownerId)), + ); +} + +function listAuthorizedProofArtifactIds( + runtime: AdeRuntime, + owners: ComputerUseArtifactOwner[], +): Set { + const artifactIds = new Set(); + for (const owner of owners) { + for (const artifact of runtime.computerUseArtifactBrokerService.listArtifacts({ + ownerKind: owner.kind, + ownerId: owner.id, + limit: 2000, + })) { + artifactIds.add(artifact.id); + } + } + return artifactIds; +} + function branchNameForPrTitle(ref: string | null | undefined): string { let value = (ref ?? "").trim(); value = value.replace(/^refs\/heads\//, ""); @@ -3356,6 +3562,7 @@ async function runTool(args: { metadata: Record; toolArgs: Record; }) => { + validateComputerUseOwnerClaims(runtime, args.sessionState, args.toolArgs); const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { name: "screencapture", @@ -4347,39 +4554,30 @@ async function runTool(args: { if (name === "ingest_computer_use_artifacts") { const backendStyle = assertComputerUseBackendStyle(toolArgs.backendStyle, "backendStyle"); const backendName = assertNonEmptyString(toolArgs.backendName, "backendName"); - const manifestPath = asOptionalTrimmedString(toolArgs.manifestPath); - let inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; - if (manifestPath) { - if (path.isAbsolute(manifestPath)) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "manifestPath must be relative to the project root"); - } - let resolvedManifest: string; - try { - resolvedManifest = resolvePathWithinRoot(runtime.projectRoot, path.resolve(runtime.projectRoot, manifestPath)); - } catch { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "manifestPath must stay within the project root"); - } - inputs = loadAgentBrowserArtifactPayloadFromFile(resolvedManifest).map((entry) => ({ - ...entry, - metadata: { - ...(isRecord(entry.metadata) ? entry.metadata : {}), - manifestPath: resolvedManifest, - }, - })); - } else if (backendName === "agent-browser" && inputs.length === 1 && isRecord(inputs[0]?.json)) { - const adapted = parseAgentBrowserArtifactPayload(inputs[0].json); - if (adapted.length > 0) { - inputs = adapted.map((entry) => ({ - ...entry, - metadata: { - ...(isRecord(entry.metadata) ? entry.metadata : {}), - adapter: "agent-browser-json", - }, - })); - } - } + const inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; if (inputs.length === 0) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs or manifestPath for computer-use ingestion."); + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs for computer-use ingestion."); + } + const authorized = await resolveAuthorizedComputerUseIngestRoot(runtime, session, toolArgs); + validateComputerUseOwnerClaims(runtime, session, toolArgs); + for (const input of inputs) { + const localPath = asOptionalTrimmedString(input.path) + ?? (() => { + const uri = asOptionalTrimmedString(input.uri); + return uri && !/^https?:\/\//i.test(uri) ? uri : null; + })(); + if (!localPath || !path.isAbsolute(localPath)) continue; + if (isPathWithinAuthorizedRoot(authorized.root, localPath)) continue; + // Absolute proof paths may also come from broker-approved external + // roots such as the OS temp directory or ~/.agent-browser. Preserve the + // lane boundary here, then let the broker enforce its full jailed + // allow-list and extension policy. + if (isPathWithinAuthorizedRoot(runtime.paths.worktreesDir, localPath)) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "Artifact paths from another lane worktree are not authorized for this caller", + ); + } } const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { @@ -4388,6 +4586,7 @@ async function runTool(args: { toolName: asOptionalTrimmedString(toolArgs.toolName), command: asOptionalTrimmedString(toolArgs.command), }, + callerRoot: authorized.callerRoot, inputs: inputs.map((entry) => ({ kind: asOptionalTrimmedString(entry.kind), title: asOptionalTrimmedString(entry.title), @@ -4400,22 +4599,140 @@ async function runTool(args: { rawType: asOptionalTrimmedString(entry.rawType), ...(isRecord(entry.metadata) ? { metadata: entry.metadata } : {}), })), - owners: resolveComputerUseOwners(session, toolArgs), + owners: resolveComputerUseOwners(session, { + ...toolArgs, + ...(authorized.laneId ? { laneId: authorized.laneId } : {}), + }), }); return result; } if (name === "list_computer_use_artifacts") { + const projectWideAuthorized = isProjectWideProofMaintenanceAuthorized(session); + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + const requestedOwnerKind = asOptionalTrimmedString(toolArgs.ownerKind) as ComputerUseArtifactOwner["kind"] | null; + const requestedOwnerId = asOptionalTrimmedString(toolArgs.ownerId); + if (!projectWideAuthorized && !authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Proof listing requires an authenticated owner scope."); + } + if ( + !projectWideAuthorized + && (requestedOwnerKind || requestedOwnerId) + && !authorizedOwners.some((owner) => owner.kind === requestedOwnerKind && owner.id === requestedOwnerId) + ) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "The requested proof owner is not authorized for this caller."); + } + if (!projectWideAuthorized) { + const limit = Math.max(1, Math.min(200, Math.floor(asNumber(toolArgs.limit, 50)))); + const kind = asOptionalTrimmedString(toolArgs.kind) as any; + const artifacts = new Map(); + const owners = requestedOwnerKind && requestedOwnerId + ? [{ kind: requestedOwnerKind, id: requestedOwnerId }] + : authorizedOwners; + for (const owner of owners) { + for (const artifact of runtime.computerUseArtifactBrokerService.listArtifacts({ + ownerKind: owner.kind, + ownerId: owner.id, + kind, + limit, + })) { + artifacts.set(artifact.id, artifact); + } + } + return { + artifacts: [...artifacts.values()] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, limit), + }; + } return { artifacts: runtime.computerUseArtifactBrokerService.listArtifacts({ - ownerKind: asOptionalTrimmedString(toolArgs.ownerKind) as any, - ownerId: asOptionalTrimmedString(toolArgs.ownerId), + ownerKind: requestedOwnerKind as any, + ownerId: requestedOwnerId, kind: asOptionalTrimmedString(toolArgs.kind) as any, limit: asNumber(toolArgs.limit, 50), }), }; } + if (name === "delete_computer_use_artifacts") { + const ids = [ + ...(asOptionalTrimmedString(toolArgs.artifactId) ? [asOptionalTrimmedString(toolArgs.artifactId)!] : []), + ...(Array.isArray(toolArgs.artifactIds) + ? toolArgs.artifactIds.map((entry) => asOptionalTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)) + : []), + ]; + if (!ids.length) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide artifactId or artifactIds to delete."); + } + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Proof deletion requires an authenticated owner scope."); + } + for (const artifactId of ids) { + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + if (artifact && !artifactMatchesAuthorizedOwners(artifact, authorizedOwners)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Artifact is not owned by this caller."); + } + } + } + return runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds: ids }); + } + + if (name === "list_broken_computer_use_artifacts") { + const requestedLimit = Math.max(1, Math.min(2000, Math.floor(asNumber(toolArgs.limit, 200)))); + const broken = runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ + limit: isProjectWideProofMaintenanceAuthorized(session) ? requestedLimit : 2000, + }); + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof listing requires an authenticated owner scope."); + } + const authorizedArtifactIds = listAuthorizedProofArtifactIds(runtime, authorizedOwners); + return { + broken: broken + .filter((entry) => authorizedArtifactIds.has(entry.artifactId)) + .slice(0, requestedLimit), + }; + } + return { + broken, + }; + } + + if (name === "prune_broken_computer_use_artifacts") { + if (isProjectWideProofMaintenanceAuthorized(session)) { + return runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + } + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof pruning requires an authenticated owner scope."); + } + const authorizedArtifactIds = listAuthorizedProofArtifactIds(runtime, authorizedOwners); + const artifactIds = runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ limit: 2000 }) + .filter((entry) => authorizedArtifactIds.has(entry.artifactId)) + .map((entry) => entry.artifactId); + return artifactIds.length + ? runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds }) + : { deleted: [], missing: [], failed: [], freedBytes: 0 }; + } + + if (name === "recover_computer_use_artifact") { + const artifactId = assertNonEmptyString(toolArgs.artifactId, "artifactId"); + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + if (!authorizedOwners.length || (artifact && !artifactMatchesAuthorizedOwners(artifact, authorizedOwners))) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Artifact is not owned by this caller."); + } + } + return runtime.computerUseArtifactBrokerService.recoverArtifact({ + artifactId, + }); + } + if (name === "get_computer_use_backend_status") { return runtime.computerUseArtifactBrokerService.getBackendStatus(); } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 4b912a23b..5105dbb66 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1581,6 +1581,11 @@ export async function createAdeRuntime(args: { captureAnalytics: (input) => { productAnalyticsService.capture(input); }, + // Removing proof files from Settings must drop their records too, + // otherwise the drawer keeps listing items whose bytes are gone. + purgeProofRecordsUnder: (removedPath) => { + computerUseArtifactBrokerService.purgeArtifactRecordsUnder(removedPath); + }, }); const budgetCapService = createBudgetCapService({ db, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 28679f38b..70d2084c7 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -5922,6 +5922,87 @@ describe("ADE CLI", () => { }); }); + it("passes the caller cwd when ingesting proof directly", () => { + const plan = buildCliPlan([ + "proof", + "ingest", + "--input-json", + JSON.stringify({ + backendStyle: "external_cli", + backendName: "agent-browser", + inputs: [{ kind: "screenshot", path: "shots/proof.png" }], + }), + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") throw new Error("Expected proof ingest to produce an execute plan"); + + expect(plan.steps[0]?.params).toMatchObject({ + name: "ingest_computer_use_artifacts", + arguments: { + callerRoot: process.cwd(), + inputs: [{ kind: "screenshot", path: "shots/proof.png" }], + }, + }); + }); + + it("resolves a relative proof attach path against the caller's cwd", () => { + // The agent's cwd is its lane worktree; the runtime storing the artifact + // runs at the project root. Resolving here is what stops the runtime from + // having to guess which tree a bare "shots/proof.png" belongs to. + const plan = buildCliPlan(["proof", "attach", "shots/proof.png"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") throw new Error("Expected proof attach to produce an execute plan"); + + const args = plan.steps[0]?.params?.arguments as Record; + expect(args.callerRoot).toBe(process.cwd()); + expect((args.inputs as Array<{ path: string }>)[0]?.path).toBe( + path.resolve(process.cwd(), "shots/proof.png"), + ); + }); + + it("maps proof rm and prune --broken to the delete actions", () => { + const rm = buildCliPlan(["proof", "rm", "artifact-1", "artifact-2"]); + expect(rm.kind).toBe("execute"); + if (rm.kind !== "execute") throw new Error("Expected proof rm to produce an execute plan"); + expect(rm.steps[0]?.params).toMatchObject({ + name: "delete_computer_use_artifacts", + arguments: { artifactIds: ["artifact-1", "artifact-2"] }, + }); + + const prune = buildCliPlan(["proof", "prune", "--broken"]); + expect(prune.kind).toBe("execute"); + if (prune.kind !== "execute") throw new Error("Expected proof prune --broken to produce an execute plan"); + expect(prune.steps[0]?.params).toMatchObject({ + name: "prune_broken_computer_use_artifacts", + }); + + // Bare `prune` only reports; removal has to be asked for explicitly. + const dryRun = buildCliPlan(["proof", "prune"]); + expect(dryRun.kind).toBe("execute"); + if (dryRun.kind !== "execute") throw new Error("Expected bare proof prune to produce an execute plan"); + expect(dryRun.steps[0]?.params).toMatchObject({ + name: "list_broken_computer_use_artifacts", + }); + }); + + it("maps proof broken and recover to artifact repair actions", () => { + const broken = buildCliPlan(["proof", "broken", "--arg", "limit=25"]); + expect(broken.kind).toBe("execute"); + if (broken.kind !== "execute") throw new Error("Expected proof broken to produce an execute plan"); + expect(broken.steps[0]?.params).toEqual({ + name: "list_broken_computer_use_artifacts", + arguments: { limit: 25 }, + }); + + const recover = buildCliPlan(["proof", "recover", "artifact-1"]); + expect(recover.kind).toBe("execute"); + if (recover.kind !== "execute") throw new Error("Expected proof recover to produce an execute plan"); + expect(recover.steps[0]?.params).toEqual({ + name: "recover_computer_use_artifact", + arguments: { artifactId: "artifact-1" }, + }); + }); + it("rejects invalid --role values", () => { expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow( /--role must be one of/, @@ -9901,6 +9982,7 @@ describe("ADE CLI", () => { arguments: { backendName: "ade-browser", toolName: "browser proof", + callerRoot: process.cwd(), inputs: [ { kind: "screenshot", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9bc4e8691..4fb670fe0 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1935,9 +1935,14 @@ const HELP_BY_COMMAND: Record = { $ ade proof list --text List captured artifacts $ ade proof capture --caption "Done" Capture a screenshot artifact $ ade proof attach /tmp/proof.png --caption "Done" Attach an existing image/video + $ ade proof rm artifact-id Delete stored proof and its record + $ ade proof broken --text List proof whose stored file is unavailable + $ ade proof recover artifact-id Re-import a broken proof from its surviving source + $ ade proof prune Preview broken proof records (does not delete) + $ ade proof prune --broken Delete every broken proof record $ ade proof record --seconds 20 Capture a short video proof $ ade proof launch --app "ADE" Launch an app for proof capture - $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external visual proof artifacts + $ ade proof ingest --input-json '{"backendStyle":"external_cli","backendName":"agent-browser","inputs":[{"kind":"screenshot","path":"/tmp/proof.png"}]}' Ingest external visual proof artifacts `, "ios-sim": `${ADE_BANNER} iOS Simulator @@ -8744,16 +8749,20 @@ function buildProofPlan(args: string[]): CliPlan { actionCallStep( "result", "ingest_computer_use_artifacts", - collectGenericObjectArgs(args), + collectGenericObjectArgs(args, { callerRoot: process.cwd() }), ), ], }; if (sub === "attach") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - const attachedPath = requireValue( + const rawPath = requireValue( readValue(args, ["--path"]) ?? firstPositional(args), "path", ); + // The agent runs in its lane worktree; the runtime that stores the artifact + // runs at the project root. Resolve here, where the caller's cwd is known, + // so a relative path never has to be guessed at on the other side. + const attachedPath = path.resolve(process.cwd(), rawPath); const title = readValue(args, ["--title", "--name"]) ?? caption ?? @@ -8769,6 +8778,7 @@ function buildProofPlan(args: string[]): CliPlan { backendStyle: "manual", backendName: "ade-cli", toolName: "proof attach", + callerRoot: process.cwd(), ...proofOwnerBase(), inputs: [ { @@ -8783,6 +8793,65 @@ function buildProofPlan(args: string[]): CliPlan { ], }; } + if (sub === "rm" || sub === "remove" || sub === "delete") { + const ids: string[] = []; + const explicitId = readValue(args, ["--id", "--artifact-id"]); + if (explicitId) ids.push(explicitId); + for (;;) { + const next = firstPositional(args); + if (!next) break; + ids.push(next); + } + if (!ids.length) requireValue(null, "artifact id"); + return { + kind: "execute", + label: "proof rm", + steps: [ + actionCallStep( + "result", + "delete_computer_use_artifacts", + collectGenericObjectArgs(args, { artifactIds: ids }), + ), + ], + }; + } + if (sub === "prune") { + const broken = readFlag(args, ["--broken"]); + return { + kind: "execute", + label: broken ? "proof prune --broken" : "proof prune", + steps: [ + broken + ? actionCallStep("result", "prune_broken_computer_use_artifacts", {}) + : actionCallStep("result", "list_broken_computer_use_artifacts", {}), + ], + }; + } + if (sub === "broken") + return { + kind: "execute", + label: "proof broken", + steps: [ + actionCallStep( + "result", + "list_broken_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "recover") { + const artifactId = requireValue( + readValue(args, ["--id", "--artifact-id"]) ?? firstPositional(args), + "artifact id", + ); + return { + kind: "execute", + label: "proof recover", + steps: [ + actionCallStep("result", "recover_computer_use_artifact", { artifactId }), + ], + }; + } if (sub === "screenshot" || sub === "capture") { const caption = readValue(args, ["--caption", "--description", "--desc"]); return { @@ -10376,6 +10445,7 @@ function buildBrowserPlan(args: string[]): CliPlan { backendStyle: "manual", backendName: "ade-browser", toolName: "browser proof", + callerRoot: process.cwd(), ...ownerBase, inputs: [ { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d2498292e..a473a10ea 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -7832,6 +7832,41 @@ describe("sync host reliability guards", () => { } }); + it("serves artifacts stored under the ade-artifact scheme the broker writes", async () => { + // Stored URIs are `ade-artifact://project/`. Without stripping + // the scheme the phone resolved a path starting with "project/" and every + // artifact read failed, even for captures that render fine on desktop. + const { projectRoot, cleanup } = createTempProjectRoot(); + const artifactPath = path.join(projectRoot, ".ade", "artifacts", "computer-use", "shot.png"); + fs.mkdirSync(path.dirname(artifactPath), { recursive: true }); + fs.writeFileSync(artifactPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const host = createReliabilityHost(projectRoot); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-artifact-scheme"); + peer.ws.send(encodeSyncEnvelope({ + type: "file_request", + requestId: "artifact-scheme", + payload: { + action: "readArtifact", + args: { uri: "ade-artifact://project/.ade/artifacts/computer-use/shot.png" }, + }, + })); + + const response = await waitForEnvelope(peer.envelopes, "file_response", "artifact-scheme"); + expect(response.payload).toMatchObject({ ok: true, action: "readArtifact" }); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + it("rejects oversized artifact reads with a clear file response", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const artifactPath = path.join(projectRoot, ".ade", "artifacts", "large.bin"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d4e606550..f17c66c8b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -5712,6 +5712,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (/^https?:\/\//i.test(candidate)) { throw new Error("Remote artifact URLs are not supported by this sync host."); } + // Stored URIs use the `ade-artifact://project/` form the renderer + // and the remote-command service both understand. Without stripping the + // scheme here the phone resolves a path that starts with "project/" and + // every artifact read fails. + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(candidate)) { + try { + candidate = decodeURIComponent(new URL(candidate).pathname.replace(/^\/+/, "")); + } catch { + throw new Error("Artifact URI is invalid."); + } + } if (/^file:\/\//i.test(candidate)) { try { candidate = fileURLToPath(candidate); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index c3404060c..175f4952a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1431,4 +1431,88 @@ describe("ade_card (TUI)", () => { const textIndex = lines.findIndex((line) => line.body.includes("meanwhile")); expect(cardIndex).toBeLessThan(textIndex); }); + + it("preserves trusted detail and labels it stale when a degraded refresh lands", () => { + const initial = card({ + rows: [{ icon: "pass", text: "lint", detail: "passed" }], + rowsTruncated: 2, + metrics: [{ label: "jobs", value: "3" }], + progress: { passed: 3, failed: 0, running: 0, queued: 0 }, + }); + const degraded = card({ + rows: [], + progress: { passed: 0, failed: 0, running: 0, queued: 0 }, + degradedReason: "HTTP 403: rate limited", + }); + + const degradedBody = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, initial), + env("2026-07-27T12:01:00.000Z", 2, degraded), + ], + }).at(-1)!.body; + expect(degradedBody).toContain("lint"); + expect(degradedBody).toContain("3 jobs"); + expect(degradedBody).toContain("+2 more"); + expect(degradedBody).toContain("detail unavailable"); + expect(degradedBody).toContain("stale"); + expect(degradedBody).toContain("HTTP 403: rate limited"); + + const recoveredBody = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, initial), + env("2026-07-27T12:01:00.000Z", 2, degraded), + env("2026-07-27T12:02:00.000Z", 3, card({ + rows: [{ icon: "pass", text: "tests", detail: "passed" }], + progress: { passed: 1, failed: 0, running: 0, queued: 0 }, + })), + ], + }).at(-1)!.body; + expect(recoveredBody).toContain("tests"); + expect(recoveredBody).not.toContain("lint"); + expect(recoveredBody).not.toContain("stale"); + expect(recoveredBody).not.toContain("detail unavailable"); + }); + + it("renders a card's measured duration with an hour unit", () => { + const body = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, card({ + durationMs: 3_723_000, + rows: [{ icon: "file", text: "report.md" }], + rowsTruncated: 4, + })), + ], + }).at(-1)!.body; + + expect(body).toContain("ran 1h 2m"); + expect(body).toContain("report.md"); + expect(body).toContain("+4 more"); + }); + + it("counts emitter-truncated rows and rows hidden by the local cap", () => { + const body = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, card({ + rows: Array.from({ length: 7 }, (_, index) => ({ + icon: "file", + text: `report-${index + 1}.md`, + })), + rowsTruncated: 3, + })), + ], + }).at(-1)!.body; + + expect(body).toContain("report-5.md"); + expect(body).not.toContain("report-6.md"); + expect(body).toContain("+5 more"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/adeCardFormat.ts b/apps/ade-cli/src/tuiClient/adeCardFormat.ts index e342d0bb2..62ae18c15 100644 --- a/apps/ade-cli/src/tuiClient/adeCardFormat.ts +++ b/apps/ade-cli/src/tuiClient/adeCardFormat.ts @@ -60,6 +60,19 @@ function adeCardProgressBar(card: AdeCardPayload): string | null { return bar.slice(0, width); } +function formatCardDuration(durationMs: number | null | undefined): string | null { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0) return null; + if (durationMs < 1000) return `${Math.max(1, Math.round(durationMs))}ms`; + const seconds = Math.round(durationMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m${remainingSeconds ? ` ${remainingSeconds}s` : ""}`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h${remainingMinutes ? ` ${remainingMinutes}m` : ""}`; +} + /** * Box-drawn `ade_card`, the TUI's ambient equivalent of the desktop card: * a row in the transcript, not a new pane. @@ -74,7 +87,8 @@ export function renderAdeCardBody(card: AdeCardPayload): string { const failing = (card.progress?.failed ?? 0) > 0 || (card.rows ?? []).some((row) => normalizeAdeCardTone(row.tone) === "warning") || (card.metrics ?? []).some((metric) => normalizeAdeCardTone(metric.tone) === "warning"); - const glyph = card.state === "live" ? "◐" : failing ? "✕" : "✓"; + const degradedReason = card.degradedReason?.trim() || null; + const glyph = card.state === "live" ? "◐" : failing ? "✕" : degradedReason ? "!" : "✓"; const heading = singleLine( [card.title, card.subtitle?.trim() || null].filter(Boolean).join(" · "), ADE_CARD_INNER_WIDTH - 6, @@ -90,13 +104,31 @@ export function renderAdeCardBody(card: AdeCardPayload): string { const metrics = (card.metrics ?? []).map((metric) => `${metric.value} ${metric.label}`.trim()); if (metrics.length) lines.push(adeCardBoxRow(metrics.join(" "))); - const rows = (card.rows ?? []).slice(0, 5); + const duration = formatCardDuration(card.durationMs); + const statusMeta = [ + card.stale ? "stale" : null, + duration ? `ran ${duration}` : null, + ].filter((value): value is string => Boolean(value)); + if (degradedReason) { + lines.push(adeCardBoxRow("! detail unavailable", statusMeta.join(" · "))); + lines.push(adeCardBoxRow(singleLine(degradedReason, ADE_CARD_INNER_WIDTH - 2))); + } else if (statusMeta.length) { + lines.push(adeCardBoxRow(statusMeta.join(" · "))); + } + + const allRows = card.rows ?? []; + const rows = allRows.slice(0, 5); + const hiddenRows = Math.max(0, card.rowsTruncated ?? 0) + + Math.max(0, allRows.length - rows.length); if (rows.length) { lines.push(adeCardBoxRow("")); for (const row of rows) { const rowGlyph = row.icon ? ADE_CARD_ROW_GLYPHS[row.icon] ?? "·" : "·"; lines.push(adeCardBoxRow(`${rowGlyph} ${row.text}`, row.detail?.trim() ?? "")); } + if (hiddenRows > 0) { + lines.push(adeCardBoxRow(`+${hiddenRows} more`)); + } } if (deeplink) { diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 05a99b680..8a4833891 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { Lexer, type Token, type Tokens } from "marked"; import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; -import type { AdeCardPayload } from "../../../desktop/src/shared/adeCard"; +import { adeCardProgressTotal, type AdeCardPayload } from "../../../desktop/src/shared/adeCard"; import { renderAdeCardBody } from "./adeCardFormat"; import { highlightCode, type HighlightedToken } from "./highlightCache"; import { glyphFor } from "./theme"; @@ -41,6 +41,43 @@ export function webSearchResultDomain(url: string | undefined | null): string { } } +/** + * Repeat card emits are patches, not replacements. Match the desktop merge + * contract so a failed refresh cannot erase detail that the TUI already showed + * as trustworthy; keep it visible and label it stale until a healthy emit + * replaces it. + */ +function mergeAdeCardPayload(existing: AdeCardPayload, incoming: AdeCardPayload): AdeCardPayload { + const merged: AdeCardPayload = { ...existing, ...incoming }; + const incomingRows = incoming.rows ?? []; + const incomingMetrics = incoming.metrics ?? []; + const incomingProgressTotal = adeCardProgressTotal(incoming.progress); + const existingHadDetail = (existing.rows?.length ?? 0) > 0 + || (existing.metrics?.length ?? 0) > 0 + || adeCardProgressTotal(existing.progress) > 0; + const incomingHasDetail = incomingRows.length > 0 + || incomingMetrics.length > 0 + || incomingProgressTotal > 0; + + if (existingHadDetail && (!incomingHasDetail || incoming.degradedReason)) { + if (!incomingRows.length && existing.rows?.length) merged.rows = existing.rows; + if (!incomingMetrics.length && existing.metrics?.length) merged.metrics = existing.metrics; + if (incomingProgressTotal === 0 && existing.progress) merged.progress = existing.progress; + if (existing.rowsTruncated != null && incoming.rowsTruncated == null) { + merged.rowsTruncated = existing.rowsTruncated; + } + merged.stale = true; + return merged; + } + + if (incomingHasDetail) { + merged.stale = incoming.stale ?? false; + merged.degradedReason = incoming.degradedReason ?? undefined; + merged.actions = incoming.actions ?? []; + } + return merged; +} + export function webSearchResultPreviewLines( results: ReadonlyArray<{ title?: string; url?: string }> | undefined, resultsTotal: number | undefined, @@ -583,7 +620,7 @@ export function renderChatLines(args: { const existing = adeCardsById.get(cardId); adeCardsById.set(cardId, { firstIndex: existing?.firstIndex ?? entry.index, - card: existing ? { ...existing.card, ...event } : event, + card: existing ? mergeAdeCardPayload(existing.card, event) : event, }); continue; } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 85cf1261b..eb020c526 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3768,6 +3768,11 @@ app.whenReady().then(async () => { captureAnalytics: (input) => { productAnalyticsService.capture(input); }, + // Removing proof files from Settings must drop their records too, + // otherwise the drawer keeps listing items whose bytes are gone. + purgeProofRecordsUnder: (removedPath) => { + computerUseArtifactBrokerService.purgeArtifactRecordsUnder(removedPath); + }, }); // Phone sync is owned by the per-machine ADE service. The desktop diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index f31071721..7a11dc80a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -370,6 +370,16 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? []; expect(actions).toContain("getBackendStatus"); expect(actions).toContain("readArtifactPreview"); + for (const action of [ + "deleteArtifacts", + "ingest", + "listArtifacts", + "listBrokenArtifacts", + "pruneBrokenArtifacts", + "recoverArtifact", + ]) { + expect(isCtoOnlyAdeAction("computer_use_artifacts", action)).toBe(true); + } }); it("exposes prompt stashes through the project runtime for connected desktops", () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 8aa3fc4cc..6757b42aa 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -237,6 +237,19 @@ export const ADE_ACTION_CTO_ONLY: Partial = { @@ -800,7 +813,18 @@ export const ADE_ACTION_ALLOWLIST: Partial { fs.rmSync(projectRoot, { recursive: true, force: true }); }); - it("routes ingested artifacts to additional owners and persists review metadata", () => { + it("persists review metadata for ingested artifacts", () => { const events: Array<{ type: string; artifactId: string }> = []; const broker = createComputerUseArtifactBrokerService({ @@ -74,13 +74,8 @@ describe("computerUseArtifactBrokerService", () => { expect(initial[0]?.reviewState).toBe("accepted"); expect(initial[0]?.workflowState).toBe("evidence_only"); - const routed = broker.routeArtifact({ - artifactId, - owner: { kind: "linear_issue", id: "issue-1" }, - }); - expect(routed.links.map((link) => `${link.ownerKind}:${link.ownerId}`)).toEqual([ + expect(initial[0]?.links.map((link) => `${link.ownerKind}:${link.ownerId}`)).toEqual([ "lane:lane-1", - "linear_issue:issue-1", ]); const reviewed = broker.updateArtifactReview({ @@ -101,11 +96,32 @@ describe("computerUseArtifactBrokerService", () => { expect(events.map((event) => event.type)).toEqual([ "artifact-linked", "artifact-ingested", - "artifact-linked", "artifact-reviewed", ]); }); + it("lists legacy lane-owned artifacts that have lane_id but no owner link", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values ('lane-only', 'project-1', 'screenshot', 'manual', 'ade-cli', null, null, + 'Lane only', null, '.ade/artifacts/computer-use/lane-only.png', 'file', 'image/png', + '{}', 'lane-1', '2026-03-12T14:00:00.000Z')`, + ); + + expect(broker.listArtifacts({ ownerKind: "lane", ownerId: "lane-1" })) + .toEqual([expect.objectContaining({ id: "lane-only", laneId: "lane-1" })]); + expect(broker.listArtifacts({ ownerKind: "lane", ownerId: "lane-2" })).toEqual([]); + }); + it("reads image previews only from the project artifact directory", async () => { const broker = createComputerUseArtifactBrokerService({ db, @@ -151,6 +167,7 @@ describe("computerUseArtifactBrokerService", () => { backend: { name: "agent-browser", }, + callerRoot: path.dirname(blockedPath), inputs: [ { kind: "console_logs", @@ -257,6 +274,763 @@ describe("computerUseArtifactBrokerService", () => { expect(row?.backend_style).toBe("manual"); }); + it("resolves a relative capture path against the caller's lane worktree", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + // Agents run inside the lane worktree, so this is where their relative + // paths point — resolving against projectRoot silently lost every capture. + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + fs.mkdirSync(path.join(laneRoot, "shots"), { recursive: true }); + fs.writeFileSync(path.join(laneRoot, "shots", "proof.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: laneRoot, + inputs: [{ kind: "screenshot", title: "Lane proof", path: "shots/proof.png" }], + }); + + const stored = ingested.artifacts[0]!; + // The bytes were copied into the artifact store, not merely referenced. + expect(stored.uri).toMatch(/^\.ade\/artifacts\/computer-use\//); + expect(fs.existsSync(path.join(projectRoot, stored.uri))).toBe(true); + expect(broker.listArtifacts({ artifactId: stored.id })[0]?.availability).toBe("available"); + }); + + it("imports proof from a server-authorized attached lane root", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-proof-")); + try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); + fs.writeFileSync( + path.join(attachedLaneRoot, "shots", "proof.png"), + proofBytes, + ); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-lane", + "project-1", + "Attached lane", + "main", + "feature/attached", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: attachedLaneRoot, + owners: [{ kind: "lane", id: "attached-lane" }], + inputs: [{ kind: "screenshot", title: "Attached proof", path: "shots/proof.png" }], + }); + + expect(broker.listArtifacts({ artifactId: ingested.artifacts[0]!.id })[0]?.availability) + .toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, ingested.artifacts[0]!.uri))).toEqual(proofBytes); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + + it("throws instead of persisting a record when the capture file is not found", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: path.join(projectRoot, ".ade", "worktrees", "lane-a"), + inputs: [{ kind: "screenshot", title: "Missing proof", path: "shots/gone.png" }], + }), + ).toThrow(/Artifact file not found: shots\/gone\.png/); + + // The whole point: no dead row survives a failed capture. + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("removes every staged file when a later batch input fails validation", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstCapture = path.join(projectRoot, "first.png"); + const secondCapture = path.join(projectRoot, "second.png"); + const rejectedCapture = path.join(projectRoot, "rejected.bin"); + fs.writeFileSync(firstCapture, "first", "utf8"); + fs.writeFileSync(secondCapture, "second", "utf8"); + fs.writeFileSync(rejectedCapture, "rejected", "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [ + { kind: "screenshot", title: "First proof", path: firstCapture }, + { kind: "screenshot", title: "Second proof", path: secondCapture }, + { kind: "screenshot", title: "Rejected proof", path: rejectedCapture }, + ], + }), + ).toThrow(/not importable as proof/); + + const stagedDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + expect(fs.existsSync(stagedDir) ? fs.readdirSync(stagedDir) : []).toEqual([]); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("deletes an artifact's rows and its stored file, and stays idempotent", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + owners: [{ kind: "chat_session", id: "chat-1" }], + inputs: [{ kind: "console_logs", title: "Notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + const filePath = path.join(projectRoot, ingested.artifacts[0]!.uri); + expect(fs.existsSync(filePath)).toBe(true); + + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted[0]).toMatchObject({ artifactId, fileRemoved: true }); + expect(fs.existsSync(filePath)).toBe(false); + expect(broker.listArtifacts({ artifactId })).toHaveLength(0); + expect( + db.all(`select id from computer_use_artifact_links where artifact_id = ?`, [artifactId]), + ).toHaveLength(0); + + // Deleting again is not an error — the file may already be gone. + const repeat = broker.deleteArtifacts({ artifactId }); + expect(repeat.missing).toEqual([artifactId]); + expect(repeat.failed).toEqual([]); + }); + + it("keeps shared stored bytes until the final artifact record is deleted", () => { + const canonicalProjectRoot = fs.realpathSync(projectRoot); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot: canonicalProjectRoot, + logger: createLogger(), + }); + const first = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared notes", text: "hello" }], + }).artifacts[0]!; + const filePath = path.join(canonicalProjectRoot, first.uri); + const second = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared notes again", path: filePath }], + }).artifacts[0]!; + + expect(second.uri).toBe(first.uri); + expect(broker.deleteArtifacts({ artifactId: first.id }).deleted[0]).toMatchObject({ + artifactId: first.id, + fileRemoved: false, + freedBytes: 0, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId: second.id })[0]?.availability).toBe("available"); + + expect(broker.deleteArtifacts({ artifactId: second.id }).deleted[0]).toMatchObject({ + artifactId: second.id, + fileRemoved: true, + }); + expect(fs.existsSync(filePath)).toBe(false); + }); + + it("keeps shared stored bytes when surviving records use an equivalent URI spelling", () => { + const canonicalProjectRoot = fs.realpathSync(projectRoot); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot: canonicalProjectRoot, + logger: createLogger(), + }); + const first = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared aliases", text: "hello" }], + }).artifacts[0]!; + const filePath = path.join(canonicalProjectRoot, first.uri); + const second = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared aliases again", path: filePath }], + }).artifacts[0]!; + db.run( + "update computer_use_artifacts set uri = ? where id = ?", + [`ade-artifact://project/${first.uri}`, second.id], + ); + + expect(broker.deleteArtifacts({ artifactId: first.id }).deleted[0]).toMatchObject({ + artifactId: first.id, + fileRemoved: false, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId: second.id })[0]?.availability).toBe("available"); + }); + + it("removes rows for records whose file was already deleted", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + fs.rmSync(path.join(projectRoot, ingested.artifacts[0]!.uri), { force: true }); + + expect(broker.listArtifacts({ artifactId })[0]?.availability).toBe("missing_file"); + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted[0]?.fileRemoved).toBe(false); + expect(broker.listArtifacts({ artifactId })).toHaveLength(0); + }); + + it("retains the file and database rows when stored-byte deletion fails", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + owners: [{ kind: "chat_session", id: "chat-1" }], + inputs: [{ kind: "console_logs", title: "Retryable notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + const filePath = path.join(projectRoot, ingested.artifacts[0]!.uri); + const canonicalFilePath = fs.realpathSync(filePath); + const originalRmSync = fs.rmSync; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => { + if (fs.realpathSync(String(candidate)) === canonicalFilePath) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return originalRmSync(candidate, options); + }) as typeof fs.rmSync); + + try { + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted).toEqual([]); + expect(result.failed).toEqual([ + { artifactId, reason: "permission denied" }, + ]); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId })).toHaveLength(1); + expect( + db.all(`select id from computer_use_artifact_links where artifact_id = ?`, [artifactId]), + ).toHaveLength(1); + } finally { + rmSpy.mockRestore(); + } + }); + + it("prunes broken records and reports where a recoverable one still lives", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + // A record shaped like the ones the old silent fallback wrote: a raw + // relative path that never made it into `.ade/artifacts`. + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + fs.mkdirSync(laneRoot, { recursive: true }); + fs.writeFileSync(path.join(laneRoot, "survivor.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "dead-1", + "project-1", + "Orphan", + "survivor.png", + JSON.stringify({ sourcePath: "survivor.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + + const broken = broker.listBrokenArtifacts(); + expect(broken).toHaveLength(1); + expect(broken[0]).toMatchObject({ artifactId: "dead-1", reason: "outside_artifact_store" }); + // Compared by suffix: the resolver realpaths, and on macOS the temp dir + // resolves through the /private symlink. + expect(broken[0]?.recoverablePath?.endsWith("/.ade/worktrees/lane-a/survivor.png")).toBe(true); + + // Recovery re-imports the surviving bytes rather than dropping the record. + const recovered = broker.recoverArtifact({ artifactId: "dead-1" }); + expect(recovered.availability).toBe("available"); + expect(broker.listBrokenArtifacts()).toHaveLength(0); + }); + + it("rejects ambiguous legacy recovery when multiple lanes contain the same relative path", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const secondLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + for (const [root, contents] of [[firstLaneRoot, "first"], [secondLaneRoot, "second"]] as const) { + fs.mkdirSync(path.join(root, "screenshots"), { recursive: true }); + fs.writeFileSync(path.join(root, "screenshots", "result.png"), contents, "utf8"); + } + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "ambiguous-legacy", + "project-1", + "Ambiguous legacy proof", + "screenshots/result.png", + JSON.stringify({ sourcePath: "screenshots/result.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + + expect(broker.listBrokenArtifacts()[0]).toMatchObject({ + artifactId: "ambiguous-legacy", + recoverablePath: null, + }); + expect(() => broker.recoverArtifact({ artifactId: "ambiguous-legacy" })) + .toThrow(/matches multiple surviving roots and cannot be recovered safely/); + expect(broker.listArtifacts({ artifactId: "ambiguous-legacy" })[0]?.uri) + .toBe("screenshots/result.png"); + }); + + it("uses an artifact's lane owner to disambiguate legacy recovery", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const secondLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + for (const [root, contents] of [[firstLaneRoot, "lane-a"], [secondLaneRoot, "lane-b"]] as const) { + fs.mkdirSync(path.join(root, "screenshots"), { recursive: true }); + fs.writeFileSync(path.join(root, "screenshots", "result.png"), contents, "utf8"); + } + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "lane-a", + "project-1", + "Lane A", + "main", + "feature/lane-a", + firstLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "owned-legacy", + "project-1", + "Owned legacy proof", + "screenshots/result.png", + JSON.stringify({ sourcePath: "screenshots/result.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifact_links( + id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at + ) values (?, ?, ?, 'lane', ?, 'attached_to', null, ?)`, + [ + "owned-legacy-link", + "owned-legacy", + "project-1", + "lane-a", + "2026-03-12T14:00:00.000Z", + ], + ); + + const recovered = broker.recoverArtifact({ artifactId: "owned-legacy" }); + expect(recovered.availability).toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri), "utf8")).toBe("lane-a"); + }); + + it("does not recover from a caller-supplied absolutePath in another lane", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const owningLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const otherLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + const originalPath = path.join(owningLaneRoot, "shots", "proof.png"); + const substitutePath = path.join(otherLaneRoot, "shots", "substitute.png"); + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.mkdirSync(path.dirname(substitutePath), { recursive: true }); + fs.writeFileSync(originalPath, "original", "utf8"); + fs.writeFileSync(substitutePath, "substitute", "utf8"); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "lane-a", + "project-1", + "Lane A", + "main", + "feature/lane-a", + owningLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: owningLaneRoot, + owners: [{ kind: "lane", id: "lane-a" }], + inputs: [{ + kind: "screenshot", + title: "Lane-scoped proof", + path: "shots/proof.png", + metadata: { absolutePath: substitutePath }, + }], + }).artifacts[0]!; + fs.rmSync(path.join(projectRoot, ingested.uri), { force: true }); + fs.rmSync(originalPath, { force: true }); + + expect(broker.listBrokenArtifacts()[0]).toMatchObject({ + artifactId: ingested.id, + recoverablePath: null, + }); + expect(() => broker.recoverArtifact({ artifactId: ingested.id })) + .toThrow(/original file.*no longer exists/i); + }); + + it("recovers a local URI capture from its attached lane root", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-uri-recovery-")); + try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const originalPath = path.join(attachedLaneRoot, "shots", "proof.png"); + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.writeFileSync(originalPath, proofBytes); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-uri-lane", + "project-1", + "Attached URI lane", + "main", + "feature/attached-uri", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: attachedLaneRoot, + owners: [{ kind: "lane", id: "attached-uri-lane" }], + inputs: [{ + kind: "screenshot", + title: "Attached URI proof", + uri: "shots/proof.png", + }], + }).artifacts[0]!; + fs.rmSync(path.join(projectRoot, ingested.uri), { force: true }); + + expect(broker.listBrokenArtifacts()[0]?.recoverablePath) + .toBe(fs.realpathSync(originalPath)); + const recovered = broker.recoverArtifact({ artifactId: ingested.id }); + expect(recovered.availability).toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri))).toEqual(proofBytes); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + + it("recovers proof from an attached lane root outside the project", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-recovery-")); + try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); + fs.writeFileSync( + path.join(attachedLaneRoot, "shots", "proof.png"), + proofBytes, + ); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-lane", + "project-1", + "Attached lane", + "main", + "feature/attached", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, ?, ?)`, + [ + "attached-broken", + "project-1", + "Attached broken proof", + "shots/proof.png", + JSON.stringify({ sourcePath: "shots/proof.png", callerRoot: attachedLaneRoot }), + "attached-lane", + "2026-03-12T14:00:00.000Z", + ], + ); + + const recovered = broker.recoverArtifact({ artifactId: "attached-broken" }); + expect(recovered.availability).toBe("available"); + expect(fs.existsSync(path.join(projectRoot, recovered.uri))).toBe(true); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri))).toEqual(proofBytes); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + + it("prunes broken records that cannot be recovered", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["dead-2", "project-1", "Gone", "nowhere/missing.png", "2026-03-12T14:00:00.000Z"], + ); + + const pruned = broker.pruneBrokenArtifacts(); + expect(pruned.deleted.map((entry) => entry.artifactId)).toEqual(["dead-2"]); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("filters for broken artifacts before applying the result limit", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, "healthy.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + [ + "new-healthy", + "project-1", + "New healthy proof", + ".ade/artifacts/computer-use/healthy.png", + "2026-03-13T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["old-broken", "project-1", "Old broken proof", "nowhere/old-broken.png", "2026-03-12T14:00:00.000Z"], + ); + + expect(broker.listBrokenArtifacts({ limit: 1 }).map((entry) => entry.artifactId)).toEqual(["old-broken"]); + }); + + it("prunes broken artifacts beyond the old 2,000-record scan cap", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, "healthy.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const healthyUri = ".ade/artifacts/computer-use/healthy.png"; + db.run( + `with recursive healthy(index_value) as ( + select 0 + union all + select index_value + 1 from healthy where index_value < 1999 + ) + insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) + select + 'healthy-' || index_value, ?, 'screenshot', 'manual', 'ade-cli', null, null, + 'Healthy ' || index_value, null, ?, 'file', null, '{}', null, ? + from healthy`, + ["project-1", healthyUri, "2026-03-13T14:00:00.000Z"], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["old-broken", "project-1", "Old broken proof", "nowhere/old-broken.png", "2026-03-12T14:00:00.000Z"], + ); + + expect(broker.pruneBrokenArtifacts().deleted.map((entry) => entry.artifactId)).toEqual(["old-broken"]); + expect(broker.listArtifacts({ artifactId: "healthy-0" })[0]?.availability).toBe("available"); + }); + + it("refuses to promote files from the project secrets directory", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + const secretPath = path.join(secretsDir, "api-keys.v1.bin"); + fs.writeFileSync(secretPath, "token", "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Keys", path: secretPath }], + }), + ).toThrow(/not allowed as a proof source/); + }); + + // The import roots include the project root and every lane worktree, because + // agents capture next to the code they are changing. That puts credential + // files in the same directories as legitimate screenshots, and the artifact + // store syncs to paired phones — so the file-type gate, not the root list, is + // what keeps them out. + it.each([ + [".env.local", "GEMINI_API_KEY=live-key"], + ["ade.db", "sqlite"], + ["ade.db-wal", "wal"], + ["id_rsa", "-----BEGIN OPENSSH PRIVATE KEY-----"], + ["server.pem", "-----BEGIN CERTIFICATE-----"], + ])("refuses to import %s from an allowed root", (name, contents) => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const sensitivePath = path.join(projectRoot, name); + fs.writeFileSync(sensitivePath, contents, "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "screenshot", title: "Proof", path: sensitivePath }], + }), + ).toThrow(/not importable as proof/); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("still imports real proof from the project root", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const shotPath = path.join(projectRoot, "capture.png"); + fs.writeFileSync(shotPath, "png-bytes", "utf8"); + + const result = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "screenshot", title: "Proof", path: shotPath }], + }); + + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0].uri).toMatch(/^\.ade\/artifacts\/computer-use\//); + }); + it("rejects symlinked artifact paths that escape the project artifact directory", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index b2350d0a3..209c80f25 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -4,6 +4,11 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { + ComputerUseArtifactAvailability, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteOutcome, + ComputerUseArtifactDeleteResult, ComputerUseArtifactIngestionRequest, ComputerUseArtifactIngestionResult, ComputerUseArtifactInput, @@ -14,7 +19,6 @@ import type { ComputerUseArtifactRecord, ComputerUseArtifactReviewArgs, ComputerUseArtifactReviewState, - ComputerUseArtifactRouteArgs, ComputerUseArtifactView, ComputerUseBackendStyle, ComputerUseBackendStatus, @@ -29,6 +33,7 @@ import type { AdeDb } from "../state/kvDb"; import type { SqlValue } from "../state/kvDb"; import { fileExists, + isEnoentError, isRecord, nowIso, resolvePathWithinRoot, @@ -52,9 +57,16 @@ type StoredArtifactRow = { storage_kind: string; mime_type: string | null; metadata_json: string; + lane_id: string | null; created_at: string; }; +const ARTIFACT_SELECT_COLUMNS = ` + id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, + metadata_json, lane_id, created_at +`; + const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "accepted"; const DEFAULT_WORKFLOW_STATE: ComputerUseArtifactWorkflowState = "evidence_only"; const ARTIFACT_PREVIEW_SIZE_CAP = 10 * 1024 * 1024; @@ -77,6 +89,18 @@ type ComputerUseArtifactRecordInsert = Omit { + const normalizedRoot = realpathOrSelf(path.resolve(root)); + return normalized === normalizedRoot || normalized.startsWith(normalizedRoot + path.sep); + }); +} + +/** Realpath when the path exists; the resolved input otherwise. */ +function realpathOrSelf(candidate: string): string { + try { + return fs.realpathSync(candidate); + } catch { + return candidate; + } +} + function resolveRendererArtifactPath(rawPath: string, projectRoot: string): string { let inputPath = rawPath; if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { @@ -209,6 +262,39 @@ function dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOw return result; } +/** + * Extensions an on-disk file may be imported with. + * + * Proof is renderable evidence — images, video, browser traces, logs. The + * import roots are deliberately wide (a lane worktree and the project root, + * because agents capture next to the code they are changing), which puts + * `.env.local`, `.ade/*.db` and any key material exactly one `ade proof attach` + * away from an artifact store that syncs to paired phones and gets linked from + * PRs. Denying roots alone cannot cover that: the sensitive files live in the + * same directories as the legitimate ones. So the allow-list is on the artifact + * itself, and anything that is not plausibly proof is refused outright. + * + * Inline text/JSON does not pass through here — {@link materializeInlineContent} + * writes its own file inside the artifacts dir and never reads a user path. + */ +const IMPORTABLE_ARTIFACT_EXTENSIONS: ReadonlySet = new Set([ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "avif", "heic", + "mp4", "webm", "mov", "avi", "mkv", + "heif", "tif", "tiff", + "m4v", "ogv", + "zip", "har", + "log", "txt", "md", +]); + +/** Extension of an import candidate, lowercased and without the dot. */ +function importExtension(absolutePath: string): string { + return path.extname(absolutePath).replace(/^\./, "").trim().toLowerCase(); +} + +function isImportableArtifactFile(absolutePath: string): boolean { + return IMPORTABLE_ARTIFACT_EXTENSIONS.has(importExtension(absolutePath)); +} + function inferArtifactExtension(input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind): string { const fromPath = toOptionalString(input.path) ?? toOptionalString(input.uri); if (fromPath) { @@ -251,6 +337,12 @@ export function createComputerUseArtifactBrokerService(args: { layout.artifactsDir, layout.cacheDir, layout.tmpDir, + // Agents run inside lane worktrees and capture next to the code they are + // changing, so a lane worktree is as legitimate a capture source as the + // project root itself. `worktreesDir` is listed explicitly because a lane + // worktree can be relocated outside `projectRoot`. + layout.worktreesDir, + projectRoot, os.tmpdir(), path.join(os.homedir(), ".agent-browser"), ...(args.additionalAllowedImportRoots ?? []) @@ -258,6 +350,7 @@ export function createComputerUseArtifactBrokerService(args: { .filter(Boolean) .map((root) => path.resolve(root)), ])); + const deniedImportRoots = [layout.secretsDir]; const emit = (payload: ComputerUseEventPayload): void => { try { @@ -267,7 +360,11 @@ export function createComputerUseArtifactBrokerService(args: { } }; - const materializeInlineContent = (input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind, title: string): string => { + const materializeInlineContent = ( + input: ComputerUseArtifactInput, + kind: ComputerUseArtifactKind, + title: string, + ): { uri: string; stagedFilePath: string } => { const extension = inferArtifactExtension(input, kind); const artifactPath = createComputerUseArtifactPath(projectRoot, title, extension); if (input.json != null) { @@ -275,57 +372,146 @@ export function createComputerUseArtifactBrokerService(args: { } else { writeTextAtomic(artifactPath, input.text ?? ""); } - return toProjectArtifactUri(projectRoot, artifactPath); + return { + uri: toProjectArtifactUri(projectRoot, artifactPath), + stagedFilePath: artifactPath, + }; }; - const resolveStoredUri = (input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind, title: string): { uri: string; storageKind: "file" | "url"; mimeType: string | null } => { + /** + * Roots a caller-supplied *relative* path is tried against, in order. + * + * Agents run inside lane worktrees, so a relative path they hand us is + * relative to the worktree, not to `projectRoot`. Resolving only against + * `projectRoot` is what silently discarded every capture taken outside + * `.ade/artifacts`. + */ + const relativeResolutionRoots = (callerRoot: string | null): string[] => { + return [path.resolve(callerRoot ?? projectRoot)]; + }; + + const resolveStoredUri = ( + input: ComputerUseArtifactInput, + kind: ComputerUseArtifactKind, + title: string, + callerRoot: string | null, + requestImportRoots: string[], + ): ResolvedStoredArtifact => { const directUri = toOptionalString(input.uri); if (directUri && isHttpUrl(directUri)) { - return { uri: directUri, storageKind: "url", mimeType: toOptionalString(input.mimeType) }; + return { + uri: directUri, + storageKind: "url", + mimeType: toOptionalString(input.mimeType), + stagedFilePath: null, + }; } const pathLike = toOptionalString(input.path) ?? (directUri && !isHttpUrl(directUri) ? directUri : null); if (pathLike) { - const absolutePath = path.isAbsolute(pathLike) - ? pathLike - : resolvePathWithinRoot(projectRoot, pathLike, { allowMissing: true }); - if (fileExists(absolutePath)) { - try { - const existingArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolutePath); - return { - uri: toProjectArtifactUri(projectRoot, existingArtifactPath), - storageKind: "file", - mimeType: toOptionalString(input.mimeType), - }; - } catch { - // Fall through to external import handling. - } - if (!isAllowedExternalArtifactSource(absolutePath, allowedImportRoots)) { - throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); + const attempted: string[] = []; + let absolutePath: string | null = null; + if (path.isAbsolute(pathLike)) { + attempted.push(pathLike); + if (fileExists(pathLike)) absolutePath = pathLike; + } else { + for (const root of relativeResolutionRoots(callerRoot)) { + let candidate: string; + try { + candidate = resolvePathWithinRoot(root, pathLike, { allowMissing: true }); + } catch { + attempted.push(path.join(root, pathLike)); + continue; + } + attempted.push(candidate); + if (fileExists(candidate)) { + absolutePath = candidate; + break; + } } - const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); - const targetPath = createComputerUseArtifactPath(projectRoot, title, extension); - secureCopyFromDescriptor(absolutePath, targetPath); + } + + if (!absolutePath) { + // Never persist a record for bytes we could not find. A row whose file + // was never imported renders as a permanently broken tile and cannot be + // recovered later, so failing loudly at capture time is the only honest + // outcome. + throw new Error( + `Artifact file not found: ${pathLike}. Tried ${attempted.join(", ") || pathLike}. ` + + `Pass an absolute path, or a path relative to ${callerRoot ?? projectRoot}.`, + ); + } + + if (isDeniedArtifactSource(absolutePath, deniedImportRoots)) { + throw new Error(`Artifact path is not allowed as a proof source: ${absolutePath}`); + } + + if (!isImportableArtifactFile(absolutePath)) { + throw new Error( + `Artifact file type is not importable as proof: ${absolutePath}. ` + + `Proof must be an image, video, browser trace, or log ` + + `(${[...IMPORTABLE_ARTIFACT_EXTENSIONS].join(", ")}).`, + ); + } + + try { + const existingArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolutePath); return { - uri: toProjectArtifactUri(projectRoot, targetPath), + uri: toProjectArtifactUri(projectRoot, existingArtifactPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: null, }; + } catch { + // Fall through to external import handling. } + if (!isAllowedExternalArtifactSource(absolutePath, [...allowedImportRoots, ...requestImportRoots])) { + throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); + } + const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); + const targetPath = createComputerUseArtifactPath(projectRoot, title, extension); + secureCopyFromDescriptor(absolutePath, targetPath); return { - uri: pathLike, + uri: toProjectArtifactUri(projectRoot, targetPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: targetPath, }; } + const materialized = materializeInlineContent(input, kind, title); return { - uri: materializeInlineContent(input, kind, title), + uri: materialized.uri, storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: materialized.stagedFilePath, }; }; + /** + * Lane an artifact belongs to, so deleting the lane deletes its captures. + * Chat sessions are `terminal_sessions` rows and carry the lane binding; an + * explicit `lane` owner wins when one was supplied. + */ + const resolveLaneIdForOwners = (owners: ComputerUseArtifactOwner[]): string | null => { + const explicitLane = owners.find((owner) => owner.kind === "lane")?.id; + if (explicitLane) return explicitLane; + for (const owner of owners) { + if (owner.kind !== "chat_session") continue; + try { + const row = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [owner.id], + ); + if (row?.lane_id) return row.lane_id; + } catch { + // The lane binding is a convenience for cleanup, never a hard + // requirement for keeping the capture. + } + } + return null; + }; + const insertArtifactRecord = (record: ComputerUseArtifactRecordInsert): ComputerUseArtifactRecord => { const { backendStyle: rawBackendStyle, ...artifactRecord } = record; const backendStyle = normalizeBackendStyle(rawBackendStyle); @@ -339,8 +525,9 @@ export function createComputerUseArtifactBrokerService(args: { ` insert into computer_use_artifacts( id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, metadata_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ next.id, @@ -356,6 +543,7 @@ export function createComputerUseArtifactBrokerService(args: { next.storageKind, next.mimeType, JSON.stringify(next.metadata ?? {}), + next.laneId ?? null, next.createdAt, ], ); @@ -395,9 +583,7 @@ export function createComputerUseArtifactBrokerService(args: { const readArtifactById = (artifactId: string): ComputerUseArtifactRecord | null => readArtifactRows( ` - select id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, - metadata_json, created_at + select ${ARTIFACT_SELECT_COLUMNS} from computer_use_artifacts where project_id = ? and id = ? @@ -406,6 +592,150 @@ export function createComputerUseArtifactBrokerService(args: { [projectId, artifactId], )[0] ?? null; + /** + * Absolute on-disk location an artifact's bytes should live at, or null when + * the record does not point inside the project artifact store. The read jail + * is the same one `readArtifactPreview` and the `ade-artifact://` protocol + * handler enforce — delete must never be able to unlink outside it. + */ + const resolveArtifactFilePath = (record: ComputerUseArtifactRecord): string | null => { + if (record.storageKind !== "file") return null; + const uri = record.uri?.trim(); + if (!uri || isHttpUrl(uri)) return null; + const candidate = resolveRendererArtifactPath(uri, projectRoot); + try { + return resolvePathWithinRoot(layout.artifactsDir, candidate, { allowMissing: true }); + } catch { + return null; + } + }; + + /** + * Where a broken record's original bytes might still be. Ingest records the + * validated path/URI and caller root in broker-stamped metadata, so a capture + * can be re-imported as long as its authorized lane worktree still exists. + */ + const resolveLaneRoots = (laneIds: Set): string[] => { + if (!laneIds.size) return []; + + const placeholders = [...laneIds].map(() => "?").join(", "); + const rows = db.all<{ worktree_path: string; attached_root_path: string | null }>( + ` + select worktree_path, attached_root_path + from lanes + where project_id = ? + and id in (${placeholders}) + `, + [projectId, ...[...laneIds].sort()], + ); + return Array.from(new Set( + rows + .flatMap((row) => [row.attached_root_path, row.worktree_path]) + .filter((root): root is string => Boolean(root)) + .map((root) => path.resolve(root)), + )).sort(); + }; + + const resolveArtifactLaneRoots = (record: ComputerUseArtifactRecord): string[] => { + const laneIds = new Set(); + if (record.laneId) laneIds.add(record.laneId); + for (const link of readLinkRows([record.id])) { + if (link.ownerKind === "lane") { + laneIds.add(link.ownerId); + continue; + } + if (link.ownerKind !== "chat_session") continue; + const session = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [link.ownerId], + ); + if (session?.lane_id) laneIds.add(session.lane_id); + } + return resolveLaneRoots(laneIds); + }; + + const resolveCandidateAcrossRoots = ( + candidate: string, + roots: string[], + ): RecoverableSourceResolution => { + const matches = new Set(); + for (const root of roots) { + let resolved: string; + try { + resolved = resolvePathWithinRoot(root, candidate, { allowMissing: true }); + } catch { + continue; + } + if (fileExists(resolved)) matches.add(realpathOrSelf(path.resolve(resolved))); + } + const paths = [...matches].sort(); + if (paths.length === 1) return { status: "found", path: paths[0]! }; + if (paths.length > 1) return { status: "ambiguous", paths }; + return { status: "missing" }; + }; + + const resolveRecoverableSource = (record: ComputerUseArtifactRecord): RecoverableSourceResolution => { + const candidates: string[] = []; + const push = (value: string | null | undefined): void => { + if (!value) return; + const trimmed = value.trim(); + if (!trimmed || isHttpUrl(trimmed)) return; + if (!candidates.includes(trimmed)) candidates.push(trimmed); + }; + push(toOptionalString(record.metadata?.sourcePath)); + push(toOptionalString(record.metadata?.sourceUri)); + push(record.uri); + + const metadataCallerRoot = toOptionalString(record.metadata?.callerRoot); + const laneRoots = resolveArtifactLaneRoots(record); + const authoritativeRoots = metadataCallerRoot + ? [path.resolve(metadataCallerRoot)] + : laneRoots.length + ? laneRoots + : null; + const fallbackRoots = [projectRoot, ...listLaneWorktreeRoots()]; + + for (const candidate of candidates) { + if (path.isAbsolute(candidate)) { + if (fileExists(candidate)) return { status: "found", path: realpathOrSelf(path.resolve(candidate)) }; + continue; + } + const resolution = resolveCandidateAcrossRoots( + candidate, + authoritativeRoots ?? fallbackRoots, + ); + if (resolution.status !== "missing") { + return resolution; + } + } + return { status: "missing" }; + }; + + const listLaneWorktreeRoots = (): string[] => { + try { + return fs.readdirSync(layout.worktreesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(layout.worktreesDir, entry.name)) + .sort(); + } catch { + return []; + } + }; + + const findRecoverableSourcePath = (record: ComputerUseArtifactRecord): string | null => { + const resolution = resolveRecoverableSource(record); + return resolution.status === "found" ? resolution.path : null; + }; + + const resolveAvailability = (record: ComputerUseArtifactRecord): ComputerUseArtifactAvailability => { + if (record.storageKind === "url" || isHttpUrl(record.uri ?? "")) return "available"; + const filePath = resolveArtifactFilePath(record); + // A file-kind record whose URI does not land inside `.ade/artifacts` was + // never imported — this is the shape the pre-fix silent fallback persisted. + if (!filePath) return "unimported"; + return fileExists(filePath) ? "available" : "missing_file"; + }; + const toArtifactView = (record: ComputerUseArtifactRecord, links: ComputerUseArtifactLink[]): ComputerUseArtifactView => { const reviewState = toOptionalString(record.metadata.reviewState) as ComputerUseArtifactReviewState | null; const workflowState = toOptionalString(record.metadata.workflowState) as ComputerUseArtifactWorkflowState | null; @@ -415,30 +745,10 @@ export function createComputerUseArtifactBrokerService(args: { reviewState: reviewState ?? DEFAULT_REVIEW_STATE, workflowState: workflowState ?? DEFAULT_WORKFLOW_STATE, reviewNote: toOptionalString(record.metadata.reviewNote), + availability: resolveAvailability(record), }; }; - const getLink = (artifactId: string, owner: ComputerUseArtifactOwner): ComputerUseArtifactLink | null => - db.get( - ` - select id, artifact_id, owner_kind, owner_id, relation, metadata_json, created_at - from computer_use_artifact_links - where artifact_id = ? - and project_id = ? - and owner_kind = ? - and owner_id = ? - and relation = ? - limit 1 - `, - [artifactId, projectId, owner.kind, owner.id.trim(), owner.relation ?? "attached_to"], - ) - ? readLinkRows([artifactId]).find((link) => - link.ownerKind === owner.kind - && link.ownerId === owner.id.trim() - && link.relation === (owner.relation ?? "attached_to") - ) ?? null - : null; - const updateArtifactMetadata = (artifactId: string, updater: (current: Record) => Record): ComputerUseArtifactView => { const current = readArtifactById(artifactId); if (!current) { @@ -475,6 +785,7 @@ export function createComputerUseArtifactBrokerService(args: { storageKind: row.storage_kind as ComputerUseArtifactRecord["storageKind"], mimeType: row.mime_type, metadata: safeJsonParse(row.metadata_json, {}), + laneId: row.lane_id ?? null, createdAt: row.created_at, })); @@ -509,21 +820,9 @@ export function createComputerUseArtifactBrokerService(args: { if (local.proofRequirements.browser_verification.available) localKinds.push("browser_verification"); if (local.proofRequirements.console_logs.available) localKinds.push("console_logs"); + // Only backends ADE can actually ingest from are listed. A permanently + // `available: false` entry is decoration, not status. const backends: ComputerUseExternalBackendStatus[] = []; - const ghostInstalled = commandExists("ghost"); - backends.push({ - name: "Ghost OS", - available: false, - state: ghostInstalled ? "installed" : "missing", - detail: ghostInstalled - ? "Ghost OS CLI is installed, but ADE Ghost integration readiness is not enabled yet." - : "Ghost OS CLI is not installed on this machine.", - supportedKinds: [ - "screenshot", - "browser_verification", - ], - }); - const agentBrowserInstalled = commandExists("agent-browser"); backends.push({ name: "agent-browser", @@ -554,18 +853,198 @@ export function createComputerUseArtifactBrokerService(args: { }; }; + /** + * Remove artifacts: rows (artifact + every link) and the stored file. + * + * Idempotent — an id with no row lands in `missing`, and a row whose file + * is already gone still has its rows removed. File removal is jailed to + * `.ade/artifacts`, so a record that points elsewhere (an http URL, or one + * of the never-imported records the old silent fallback wrote) only ever + * loses its rows. + */ + const deleteArtifacts = (args: ComputerUseArtifactDeleteArgs): ComputerUseArtifactDeleteResult => { + const ids = Array.from(new Set([ + ...(toOptionalString(args?.artifactId) ? [toOptionalString(args.artifactId)!] : []), + ...((args?.artifactIds ?? []).map((id) => toOptionalString(id)).filter((id): id is string => Boolean(id))), + ])); + if (!ids.length) throw new Error("artifactId or artifactIds is required."); + + const deleted: ComputerUseArtifactDeleteOutcome[] = []; + const missing: string[] = []; + const failed: Array<{ artifactId: string; reason: string }> = []; + + for (const artifactId of ids) { + const record = readArtifactById(artifactId); + if (!record) { + missing.push(artifactId); + continue; + } + // Read the owners before the rows go, so the delete can be announced to + // the same surfaces `artifact-linked` reached. An `owner: null` event is + // silently dropped by every listener, which is why deletes made outside + // the drawer used to leave it showing a row that no longer existed. + const owners = readLinkRows([artifactId]).map((link) => ({ + kind: link.ownerKind, + id: link.ownerId, + relation: link.relation, + })); + try { + const filePath = resolveArtifactFilePath(record); + let fileRemoved = false; + let freedBytes = 0; + const sharedReference = filePath && record.storageKind === "file" + ? readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + and id <> ? + `, + [projectId, artifactId], + ).some((candidate) => resolveArtifactFilePath(candidate) === filePath) + : false; + if (filePath && !sharedReference) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error(`Artifact storage path is not a regular file: ${filePath}`); + } + freedBytes = stat.size; + fs.rmSync(filePath, { force: true }); + fileRemoved = true; + } catch (error) { + // Absence is idempotent. Permission, lock, I/O, and other failures + // must retain both rows so the deletion can be retried rather than + // orphaning bytes that ADE can no longer manage. + if (!isEnoentError(error)) throw error; + } + } + db.run("delete from computer_use_artifact_links where artifact_id = ?", [artifactId]); + db.run("delete from computer_use_artifacts where id = ? and project_id = ?", [artifactId, projectId]); + deleted.push({ + artifactId, + title: record.title, + fileRemoved, + path: filePath, + freedBytes, + }); + const deletedAt = nowIso(); + if (owners.length) { + for (const owner of owners) { + emit({ type: "artifact-deleted", artifactId, at: deletedAt, owner }); + } + } else { + emit({ type: "artifact-deleted", artifactId, at: deletedAt, owner: null }); + } + } catch (error) { + failed.push({ + artifactId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + deleted, + missing, + failed, + freedBytes: deleted.reduce((sum, entry) => sum + entry.freedBytes, 0), + }; + }; + + /** + * Every record whose bytes cannot be served, with enough detail for the UI + * to say what happened and offer recovery when the original file survives. + */ + const collectBrokenArtifacts = (limit: number | null): ComputerUseArtifactBrokenRecord[] => { + const pageSize = 500; + const broken: ComputerUseArtifactBrokenRecord[] = []; + let offset = 0; + + for (;;) { + const records = readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + order by created_at desc, id desc + limit ? offset ? + `, + [projectId, pageSize, offset], + ); + for (const record of records) { + const availability = resolveAvailability(record); + if (availability === "available") continue; + broken.push({ + artifactId: record.id, + title: record.title, + kind: record.kind, + uri: record.uri, + createdAt: record.createdAt, + reason: availability === "unimported" ? "outside_artifact_store" : "missing_file", + laneId: record.laneId ?? null, + recoverablePath: findRecoverableSourcePath(record), + }); + if (limit != null && broken.length >= limit) return broken; + } + if (records.length < pageSize) return broken; + offset += records.length; + } + }; + + const listBrokenArtifacts = (args: { limit?: number } = {}): ComputerUseArtifactBrokenRecord[] => { + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 1000))); + return collectBrokenArtifacts(limit); + }; + return { ingest(request: ComputerUseArtifactIngestionRequest): ComputerUseArtifactIngestionResult { const owners = dedupeOwners(request.owners ?? []); - const artifacts = request.inputs.map((input) => { - const kind = normalizeInputKind(input); - const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); - const { uri, storageKind, mimeType } = resolveStoredUri(input, kind, title); + const callerRoot = toOptionalString(request.callerRoot); + const laneId = resolveLaneIdForOwners(owners); + // The broker accepts extra roots only from its own lane table. The RPC + // supplies `callerRoot` after authenticating it, but callers cannot turn + // an arbitrary path into an import root merely by placing it in metadata. + const requestImportRoots = laneId ? resolveLaneRoots(new Set([laneId])) : []; + // Resolve every input before persisting any of them. `resolveStoredUri` + // now throws (missing file, non-importable type, denied source), and a + // half-committed batch would leave the agent's retry inserting the + // already-stored inputs a second time. + const resolved: Array<{ + input: ComputerUseArtifactInput; + kind: ComputerUseArtifactKind; + title: string; + stored: ResolvedStoredArtifact; + }> = []; + const stagedFilePaths: string[] = []; + try { + for (const input of request.inputs) { + const kind = normalizeInputKind(input); + const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); + const stored = resolveStoredUri(input, kind, title, callerRoot, requestImportRoots); + if (stored.stagedFilePath) stagedFilePaths.push(stored.stagedFilePath); + resolved.push({ input, kind, title, stored }); + } + } catch (error) { + for (const stagedFilePath of stagedFilePaths) { + try { + fs.rmSync(stagedFilePath, { force: true }); + } catch { + // Preserve the validation error; cleanup is best-effort. + } + } + throw error; + } + const artifacts = resolved.map(({ input, kind, title, stored }) => { + const { uri, storageKind, mimeType } = stored; const metadata = { ...(isRecord(input.metadata) ? input.metadata : {}), sourcePath: toOptionalString(input.path), sourceUri: toOptionalString(input.uri), rawType: toOptionalString(input.rawType), + ...(callerRoot ? { callerRoot } : {}), }; const record = insertArtifactRecord({ kind, @@ -579,6 +1058,7 @@ export function createComputerUseArtifactBrokerService(args: { storageKind, mimeType, metadata, + laneId, }); for (const owner of owners) { insertLink(record.id, owner); @@ -604,7 +1084,10 @@ export function createComputerUseArtifactBrokerService(args: { }, listArtifacts(args: ComputerUseArtifactListArgs = {}): ComputerUseArtifactView[] { - const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); + // Public callers cap ordinary list responses at 200. Internal proof + // maintenance may request up to the broken-record audit ceiling so it + // can authorize a bounded batch without one lookup per artifact. + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 50))); let artifacts: ComputerUseArtifactRecord[] = []; const artifactId = toOptionalString(args.artifactId); if (artifactId) { @@ -616,30 +1099,30 @@ export function createComputerUseArtifactBrokerService(args: { if (ownerKind && ownerId) { artifacts = readArtifactRows( ` - select a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, + select distinct a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, a.original_type, a.title, a.description, a.uri, a.storage_kind, a.mime_type, - a.metadata_json, a.created_at + a.metadata_json, a.lane_id, a.created_at from computer_use_artifacts a - inner join computer_use_artifact_links l + left join computer_use_artifact_links l on l.artifact_id = a.id + and l.project_id = ? where a.project_id = ? - and l.project_id = ? - and l.owner_kind = ? - and l.owner_id = ? + and ( + (l.owner_kind = ? and l.owner_id = ?) + or (? = 'lane' and a.lane_id = ?) + ) ${args.kind ? "and a.artifact_kind = ?" : ""} order by a.created_at desc limit ? `, args.kind - ? [projectId, projectId, ownerKind, ownerId, args.kind, limit] - : [projectId, projectId, ownerKind, ownerId, limit], + ? [projectId, projectId, ownerKind, ownerId, ownerKind, ownerId, args.kind, limit] + : [projectId, projectId, ownerKind, ownerId, ownerKind, ownerId, limit], ); } else { artifacts = readArtifactRows( ` - select id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, - metadata_json, created_at + select ${ARTIFACT_SELECT_COLUMNS} from computer_use_artifacts where project_id = ? ${args.kind ? "and artifact_kind = ?" : ""} @@ -660,24 +1143,138 @@ export function createComputerUseArtifactBrokerService(args: { return artifacts.map((artifact) => toArtifactView(artifact, linksByArtifact.get(artifact.id) ?? [])); }, - routeArtifact(args: ComputerUseArtifactRouteArgs): ComputerUseArtifactView { - const artifactId = String(args.artifactId ?? "").trim(); - if (!artifactId.length) throw new Error("artifactId is required."); - const owner = { ...args.owner, id: args.owner.id.trim() }; - if (!owner.id.length) throw new Error("owner.id is required."); + + deleteArtifacts, + listBrokenArtifacts, + + /** Drop every unrenderable record. Files were never there to remove. */ + pruneBrokenArtifacts(): ComputerUseArtifactDeleteResult { + const broken = collectBrokenArtifacts(null); + if (!broken.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: broken.map((entry) => entry.artifactId) }); + }, + + /** + * Re-import a broken record's original file when it still exists on disk, + * turning a dead row back into a viewable artifact. + */ + recoverArtifact(args: { artifactId: string }): ComputerUseArtifactView { + const artifactId = toOptionalString(args?.artifactId); + if (!artifactId) throw new Error("artifactId is required."); const record = readArtifactById(artifactId); if (!record) throw new Error(`Computer-use artifact not found: ${artifactId}`); - const existing = getLink(artifactId, owner); - if (!existing) { - insertLink(artifactId, owner); - emit({ - type: "artifact-linked", - artifactId, - at: nowIso(), - owner, - }); + if (resolveAvailability(record) === "available") { + return toArtifactView(record, readLinkRows([artifactId])); } - return toArtifactView(record, readLinkRows([artifactId])); + const source = resolveRecoverableSource(record); + if (source.status === "ambiguous") { + throw new Error( + `The original file for "${record.title}" matches multiple surviving roots and cannot be recovered safely: ` + + source.paths.join(", "), + ); + } + if (source.status === "missing") { + throw new Error( + `The original file for "${record.title}" no longer exists, so it cannot be recovered. Remove the record instead.`, + ); + } + const sourcePath = source.path; + const artifactLaneRoots = resolveArtifactLaneRoots(record); + if (!isAllowedExternalArtifactSource(sourcePath, [...allowedImportRoots, ...artifactLaneRoots]) + || isDeniedArtifactSource(sourcePath, deniedImportRoots)) { + throw new Error(`Artifact path is outside allowed import roots: ${sourcePath}`); + } + // Recovery re-imports bytes from a surviving lane worktree, so it is an + // import like any other and gets the same file-type gate. + if (!isImportableArtifactFile(sourcePath)) { + throw new Error(`Artifact file type is not importable as proof: ${sourcePath}`); + } + const extension = inferArtifactExtension({ path: sourcePath }, record.kind); + const targetPath = createComputerUseArtifactPath(projectRoot, record.title, extension); + secureCopyFromDescriptor(sourcePath, targetPath); + db.run( + "update computer_use_artifacts set uri = ?, storage_kind = 'file' where id = ? and project_id = ?", + [toProjectArtifactUri(projectRoot, targetPath), artifactId, projectId], + ); + const refreshed = readArtifactById(artifactId); + if (!refreshed) throw new Error(`Failed to refresh computer-use artifact: ${artifactId}`); + return toArtifactView(refreshed, readLinkRows([artifactId])); + }, + + /** + * Lane cascade. Deleting a lane deletes the captures taken in it — that is + * the behaviour users expect from "delete the lane and its stuff". + * Artifacts also linked to a chat outside the lane are kept so a shared + * capture never disappears from a chat the user did not delete. + */ + deleteArtifactsForLane(args: { laneId: string }): ComputerUseArtifactDeleteResult { + const laneId = toOptionalString(args?.laneId); + if (!laneId) throw new Error("laneId is required."); + const rows = db.all<{ id: string }>( + ` + select a.id + from computer_use_artifacts a + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links l + where l.artifact_id = a.id + and l.owner_kind = 'lane' + and l.owner_id = ? + ) + ) + and not exists ( + select 1 + from computer_use_artifact_links l2 + join terminal_sessions s on s.id = l2.owner_id + where l2.artifact_id = a.id + and l2.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + ) + `, + [projectId, laneId, laneId, laneId], + ); + if (!rows.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: rows.map((row) => row.id) }); + }, + + /** Drop every artifact record for the project (used by "clear local data"). */ + deleteAllArtifacts(): ComputerUseArtifactDeleteResult { + const rows = db.all<{ id: string }>( + "select id from computer_use_artifacts where project_id = ?", + [projectId], + ); + if (!rows.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: rows.map((row) => row.id) }); + }, + + /** + * Drop records whose stored file lived at or under `removedPath`. Called + * after Settings → Storage removes proof files so the rows never outlive + * the bytes. + */ + purgeArtifactRecordsUnder(removedPath: string): ComputerUseArtifactDeleteResult { + const root = path.resolve(removedPath); + const rows = readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + `, + [projectId], + ); + const ids = rows + .filter((record) => { + const filePath = resolveArtifactFilePath(record); + if (!filePath) return false; + return filePath === root || filePath.startsWith(root + path.sep); + }) + .map((record) => record.id); + if (!ids.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: ids }); }, updateArtifactReview(args: ComputerUseArtifactReviewArgs): ComputerUseArtifactView { diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts index 03cd28004..a822e5702 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts @@ -37,8 +37,33 @@ function createBackendStatus(): ComputerUseBackendStatus { }; } +function createArtifact(overrides: Record = {}) { + return { + id: "artifact-1", + kind: "screenshot", + backendStyle: "manual", + backendName: "ade-cli", + sourceToolName: null, + originalType: null, + title: "Checkout screen", + description: null, + uri: ".ade/artifacts/computer-use/shot.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + laneId: "lane-1", + createdAt: "2026-03-12T14:00:00.000Z", + links: [], + reviewState: "accepted", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + ...overrides, + } as any; +} + describe("computer use control plane", () => { - it("shows live backend activity when a backend is available", () => { + it("reports backend readiness in the summary without fabricating activity events", () => { const snapshot = buildComputerUseOwnerSnapshot({ broker: { getBackendStatus: vi.fn(() => createBackendStatus()), @@ -48,7 +73,39 @@ describe("computer use control plane", () => { }); expect(snapshot.summary).toContain("Ghost OS is available and ready to capture proof"); - expect(snapshot.activity.some((item) => item.kind === "backend_available")).toBe(true); + // Backend readiness is a current condition, not something that happened. + // It belongs in the summary, never as a feed row stamped "just now". + expect(snapshot.activity).toEqual([]); + }); + + it("derives activity from stored artifacts and keeps their real timestamps", () => { + const snapshot = buildComputerUseOwnerSnapshot({ + broker: { + getBackendStatus: vi.fn(() => createBackendStatus()), + listArtifacts: vi.fn(() => [createArtifact()]), + } as any, + owner: { kind: "chat_session", id: "chat-1" }, + }); + + expect(snapshot.activity).toHaveLength(1); + expect(snapshot.activity[0]).toMatchObject({ + kind: "artifact_ingested", + artifactId: "artifact-1", + at: "2026-03-12T14:00:00.000Z", + severity: "success", + }); + }); + + it("flags artifacts whose bytes were never imported as warnings", () => { + const snapshot = buildComputerUseOwnerSnapshot({ + broker: { + getBackendStatus: vi.fn(() => createBackendStatus()), + listArtifacts: vi.fn(() => [createArtifact({ availability: "unimported" })]), + } as any, + owner: { kind: "chat_session", id: "chat-1" }, + }); + + expect(snapshot.activity[0]?.severity).toBe("warning"); }); it("collects only supported proof kinds from required phases", () => { diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.ts b/apps/desktop/src/main/services/computerUse/controlPlane.ts index 8fd8e6bee..3d95b0d99 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.ts @@ -3,7 +3,6 @@ import type { ComputerUseArtifactOwner, ComputerUseActivityItem, ComputerUseArtifactView, - ComputerUseBackendStatus, ComputerUseOwnerSnapshot, } from "../../../shared/types"; import type { ComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; @@ -43,42 +42,15 @@ export function collectRequiredComputerUseKindsFromPhases( return Array.from(required); } -function buildActivity( - artifacts: ComputerUseArtifactView[], - backendStatus: ComputerUseBackendStatus, -) : ComputerUseActivityItem[] { - const liveBackendActivity: ComputerUseActivityItem[] = []; - for (const backend of backendStatus.backends.slice(0, 4)) { - const at = new Date().toISOString(); - if (backend.available && backend.state === "installed") { - liveBackendActivity.push({ - id: `backend:${backend.name}:available`, - at, - kind: "backend_available", - title: `${backend.name} ready`, - detail: backend.detail, - backendName: backend.name, - artifactId: null, - severity: "success", - }); - continue; - } - if (!backend.available || backend.state === "missing") { - liveBackendActivity.push({ - id: `backend:${backend.name}:unavailable`, - at, - kind: "backend_unavailable", - title: `${backend.name} unavailable`, - detail: backend.detail, - backendName: backend.name, - artifactId: null, - severity: "warning", - }); - continue; - } - } - - const artifactActivity = artifacts.slice(0, 6).map((artifact) => ({ +/** + * Activity is a record of things that happened, so every entry is derived from + * a stored artifact and carries that artifact's real timestamp. Backend + * readiness is a *current* condition, not an event — synthesizing feed rows for + * it stamped with `Date.now()` put fabricated "just now" entries at the top of + * every scope, which is why that half is gone. + */ +function buildActivity(artifacts: ComputerUseArtifactView[]): ComputerUseActivityItem[] { + return artifacts.slice(0, 8).map((artifact) => ({ id: `artifact:${artifact.id}`, at: artifact.createdAt, kind: "artifact_ingested" as const, @@ -86,12 +58,10 @@ function buildActivity( detail: `${artifact.backendName} produced ${artifact.title}.`, artifactId: artifact.id, backendName: artifact.backendName, - severity: artifact.reviewState === "accepted" ? "success" as const : "info" as const, + severity: artifact.availability === "missing_file" || artifact.availability === "unimported" + ? ("warning" as const) + : ("success" as const), })); - - return [...liveBackendActivity, ...artifactActivity] - .sort((left, right) => Date.parse(right.at) - Date.parse(left.at)) - .slice(0, 8); } export function buildComputerUseOwnerSnapshot(args: { @@ -138,6 +108,6 @@ export function buildComputerUseOwnerSnapshot(args: { activeBackend, artifacts, recentArtifacts, - activity: buildActivity(artifacts, backendStatus), + activity: buildActivity(artifacts), }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 560d6c635..b72ad8079 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -568,7 +568,9 @@ import type { BudgetCapConfig, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactView, ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, @@ -7757,9 +7759,24 @@ export function registerIpc({ }); }); - ipcMain.handle(IPC.computerUseRouteArtifact, async (_event, arg: ComputerUseArtifactRouteArgs): Promise => { + ipcMain.handle(IPC.computerUseDeleteArtifacts, async (_event, arg: ComputerUseArtifactDeleteArgs): Promise => { const ctx = ensureComputerUseBroker(); - return ctx.computerUseArtifactBrokerService.routeArtifact(arg); + return ctx.computerUseArtifactBrokerService.deleteArtifacts(arg); + }); + + ipcMain.handle(IPC.computerUseListBrokenArtifacts, async (_event, arg: { limit?: number } = {}): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.listBrokenArtifacts(arg); + }); + + ipcMain.handle(IPC.computerUsePruneBrokenArtifacts, async (): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + }); + + ipcMain.handle(IPC.computerUseRecoverArtifact, async (_event, arg: { artifactId: string }): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.recoverArtifact(arg); }); ipcMain.handle(IPC.computerUseUpdateArtifactReview, async (_event, arg: ComputerUseArtifactReviewArgs): Promise => { diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 9926d4570..dfffb6996 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3242,6 +3242,155 @@ describe("laneService delete teardown + cancellation + streaming", () => { return { db, service, repoRoot, worktreesDir, childPath }; } + it("transfers shared proof ownership so the final owning lane can delete it", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const artifactsDir = path.join(repoRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactsDir, { recursive: true }); + const ownedFile = path.join(artifactsDir, "owned.png"); + const sharedFile = path.join(artifactsDir, "shared.png"); + fs.writeFileSync(ownedFile, "a"); + fs.writeFileSync(sharedFile, "b"); + + const insertArtifact = (id: string, laneId: string, relative: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', ?, ?)`, + [id, id, relative, laneId, "2026-03-12T14:00:00.000Z"], + ); + }; + insertArtifact("art-owned", "lane-child", ".ade/artifacts/computer-use/owned.png"); + insertArtifact("art-shared", "lane-child", ".ade/artifacts/computer-use/shared.png"); + + // The shared artifact is also attached to a chat that lives in another + // lane, so deleting this lane must not take it away from that chat. + db.run( + `insert into terminal_sessions(id, lane_id, title, status, started_at, transcript_path) + values (?, ?, ?, 'exited', ?, ?)`, + ["chat-other", "lane-parent", "Other lane chat", "2026-03-12T14:00:00.000Z", "/tmp/other.log"], + ); + db.run( + `insert into computer_use_artifact_links( + id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at + ) values (?, ?, ?, 'chat_session', ?, 'attached_to', null, ?)`, + ["link-1", "art-shared", "proj-delete", "chat-other", "2026-03-12T14:00:00.000Z"], + ); + + await service.delete({ laneId: "lane-child", deleteBranch: false }); + + const remaining = db.all<{ id: string; lane_id: string | null }>( + "select id, lane_id from computer_use_artifacts", + ); + expect(remaining).toEqual([{ id: "art-shared", lane_id: "lane-parent" }]); + expect(fs.existsSync(ownedFile)).toBe(false); + expect(fs.existsSync(sharedFile)).toBe(true); + + await service.delete({ laneId: "lane-parent", deleteBranch: false }); + + const afterFinalOwnerDelete = db.all<{ id: string }>("select id from computer_use_artifacts"); + expect(afterFinalOwnerDelete).toEqual([]); + expect(fs.existsSync(sharedFile)).toBe(false); + }); + + it("keeps a proof file when a surviving artifact row uses an equivalent URI spelling", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const relativeUri = ".ade/artifacts/computer-use/shared-uri.png"; + const artifactFile = path.join(repoRoot, relativeUri); + fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); + fs.writeFileSync(artifactFile, "shared"); + + const insertArtifact = (id: string, laneId: string, uri: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', ?, ?)`, + [id, id, uri, laneId, "2026-03-12T14:00:00.000Z"], + ); + }; + insertArtifact("art-deleted", "lane-child", relativeUri); + insertArtifact("art-survivor", "lane-parent", `ade-artifact://project/${relativeUri}`); + + await service.delete({ laneId: "lane-child", deleteBranch: false }); + + const remaining = db.all<{ id: string }>("select id from computer_use_artifacts").map((row) => row.id); + expect(remaining).toContain("art-survivor"); + expect(remaining).not.toContain("art-deleted"); + expect(fs.existsSync(artifactFile)).toBe(true); + }); + + it("reports only proof files actually removed when lane cleanup partially fails", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const artifactsDir = path.join(repoRoot, ".ade", "artifacts", "computer-use"); + const removedFile = path.join(artifactsDir, "removed.png"); + const failedFile = path.join(artifactsDir, "failed.png"); + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.writeFileSync(removedFile, "removed"); + fs.writeFileSync(failedFile, "failed"); + for (const [id, uri] of [ + ["removed", ".ade/artifacts/computer-use/removed.png"], + ["failed", ".ade/artifacts/computer-use/failed.png"], + ]) { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', 'lane-child', ?)`, + [id, id, uri, "2026-03-12T14:00:00.000Z"], + ); + } + const originalRmSync = fs.rmSync; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => { + if (path.resolve(String(candidate)) === fs.realpathSync(failedFile)) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return originalRmSync(candidate, options); + }) as typeof fs.rmSync); + + try { + await service.delete({ laneId: "lane-child", deleteBranch: false }); + } finally { + rmSpy.mockRestore(); + } + + const last = events[events.length - 1]; + expect(last.progress.steps.find((step: any) => step.name === "database_cleanup")?.detail) + .toBe("1 of 2 proof file(s) removed; see logs"); + expect(fs.existsSync(removedFile)).toBe(false); + expect(fs.existsSync(failedFile)).toBe(true); + }); + it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b0672f418..8e5a6d105 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; import { deletePullRequestRowsByIds, deletePullRequestRowsForLane } from "../prs/pullRequestRowCleanup"; -import { isWithinDir, normalizeBranchName } from "../shared/utils"; +import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; @@ -166,6 +166,39 @@ type SessionLinearIssueLinkRow = { const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; +/** + * Proof artifacts a lane owns, so deleting the lane can delete them. + * + * A capture belongs to the lane when its `lane_id` says so (populated at + * ingest) or when it carries an explicit `lane` link (pre-migration rows). A + * capture that is *also* attached to a chat session living in a different lane + * is excluded — deleting one lane must not remove proof another lane's chat + * still shows. + * + * Parameter order: projectId, laneId, laneId, laneId. + */ +const LANE_OWNED_ARTIFACT_IDS_SQL = ` + select a.id + from computer_use_artifacts a + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links l + where l.artifact_id = a.id and l.owner_kind = 'lane' and l.owner_id = ? + ) + ) + and not exists ( + select 1 + from computer_use_artifact_links l2 + join terminal_sessions s on s.id = l2.owner_id + where l2.artifact_id = a.id + and l2.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + ) +`; + function isPermissionError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | undefined)?.code; return code === "EACCES" || code === "EPERM"; @@ -3011,6 +3044,96 @@ export function createLaneService({ } }; + /** + * Absolute paths of the proof files owned by a lane, resolved through the + * same `.ade/artifacts` jail the broker and the `ade-artifact://` handler + * enforce. Anything that does not land inside the artifact store is skipped + * rather than unlinked — a lane delete must never be able to remove a file + * outside ADE's own storage. + * + * Must be called before `cleanupLaneDatabaseRows`, which removes the rows + * these paths are read from. + */ + const collectLaneArtifactFilePaths = (laneId: string): string[] => { + const artifactsDir = resolveAdeLayout(projectRoot).artifactsDir; + let ownedIds: Set; + let rows: Array<{ id: string; uri: string | null }>; + try { + ownedIds = new Set(db.all<{ id: string }>( + ` + with lane_owned_artifacts as ( + ${LANE_OWNED_ARTIFACT_IDS_SQL} + ) + select id from lane_owned_artifacts + `, + [projectId, laneId, laneId, laneId], + ).map((row) => row.id)); + rows = db.all<{ id: string; uri: string | null }>( + ` + select id, uri + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + `, + [projectId], + ); + } catch (error) { + logger.warn("lane.delete.artifact_paths_query_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + const resolveJailedArtifactPath = (rawUri: string | null): string | null => { + const uri = typeof rawUri === "string" ? rawUri.trim() : ""; + if (!uri || /^https?:\/\//i.test(uri)) return null; + let relative = uri; + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(relative)) { + try { + relative = decodeURIComponent(new URL(relative).pathname.replace(/^\/+/, "")); + } catch { + return null; + } + } + const absolute = path.resolve(path.isAbsolute(relative) ? relative : path.join(projectRoot, relative)); + try { + return resolvePathWithinRoot(artifactsDir, absolute, { allowMissing: true }); + } catch { + return null; + } + }; + const survivorPaths = new Set( + rows + .filter((row) => !ownedIds.has(row.id)) + .map((row) => resolveJailedArtifactPath(row.uri)) + .filter((candidate): candidate is string => Boolean(candidate)), + ); + const paths = new Set(); + for (const row of rows) { + if (!ownedIds.has(row.id)) continue; + const jailed = resolveJailedArtifactPath(row.uri); + if (jailed && !survivorPaths.has(jailed)) paths.add(jailed); + } + return [...paths]; + }; + + const removeLaneArtifactFiles = (laneId: string, filePaths: string[]): number => { + let removed = 0; + for (const filePath of filePaths) { + try { + fs.rmSync(filePath, { force: true }); + removed += 1; + } catch (error) { + logger.warn("lane.delete.artifact_file_remove_failed", { + laneId, + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return removed; + }; + const cleanupLaneDatabaseRows = (laneId: string): void => { db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update lane_branch_profiles set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); @@ -3050,6 +3173,68 @@ export function createLaneService({ `, [projectId, laneId, laneId, laneId], ); + // Proof captured in this lane goes with the lane. Transfer shared captures + // to a surviving chat's lane before deleting this lane's ownership; the + // later delete can then collect the capture when its final lane is removed. + // This has to run while `terminal_sessions` still exists because both the + // transfer and the guard that spares shared captures join that table. + db.run( + ` + update computer_use_artifacts as a + set lane_id = ( + select s.lane_id + from computer_use_artifact_links l + join terminal_sessions s on s.id = l.owner_id + join lanes surviving_lane on surviving_lane.id = s.lane_id + where l.artifact_id = a.id + and l.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + and surviving_lane.project_id = ? + order by l.created_at asc, s.id asc + limit 1 + ) + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links owned_link + where owned_link.artifact_id = a.id + and owned_link.owner_kind = 'lane' + and owned_link.owner_id = ? + ) + ) + and exists ( + select 1 + from computer_use_artifact_links surviving_link + join terminal_sessions surviving_session on surviving_session.id = surviving_link.owner_id + join lanes surviving_lane on surviving_lane.id = surviving_session.lane_id + where surviving_link.artifact_id = a.id + and surviving_link.owner_kind = 'chat_session' + and surviving_session.lane_id is not null + and surviving_session.lane_id <> ? + and surviving_lane.project_id = ? + ) + `, + [laneId, projectId, projectId, laneId, laneId, laneId, projectId], + ); + db.run(`delete from computer_use_artifacts where id in (${LANE_OWNED_ARTIFACT_IDS_SQL})`, [ + projectId, laneId, laneId, laneId, + ]); + // Links for the deleted artifacts, plus links from surviving (shared) + // artifacts to the chat sessions this lane is about to remove. + db.run( + ` + delete from computer_use_artifact_links + where artifact_id not in (select id from computer_use_artifacts) + or ( + owner_kind = 'chat_session' + and owner_id in (select id from terminal_sessions where lane_id = ?) + ) + or (owner_kind = 'lane' and owner_id = ?) + `, + [laneId, laneId], + ); db.run("delete from claude_sessions where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); db.run("delete from operations where lane_id = ? and project_id = ?", [laneId, projectId]); @@ -6103,6 +6288,10 @@ export function createLaneService({ }); await runStep("database_cleanup", async () => { + // Read the proof file paths before the rows go, then unlink only + // after the transaction commits: a rollback must not leave files + // deleted out from under rows that survived. + const laneArtifactFiles = collectLaneArtifactFilePaths(laneId); db.run("begin immediate"); try { cleanupLaneDatabaseRows(laneId); @@ -6115,6 +6304,11 @@ export function createLaneService({ } throw error; } + const removedProofFiles = removeLaneArtifactFiles(laneId, laneArtifactFiles); + if (!laneArtifactFiles.length || removedProofFiles === 0) return undefined; + return removedProofFiles === laneArtifactFiles.length + ? { detail: `${removedProofFiles} proof file(s) removed` } + : { detail: `${removedProofFiles} of ${laneArtifactFiles.length} proof file(s) removed; see logs` }; }); invalidateLaneListCache(); diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 3e78121e4..8890d4924 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import YAML from "yaml"; import type { AdeDb } from "../state/kvDb"; import type { @@ -13,6 +14,7 @@ import type { } from "../../../shared/types"; import { buildAdeGitignore, ADE_LAYOUT_DEFINITIONS, resolveAdeLayout, type AdeLayoutPaths } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; +import { resolvePathWithinRoot } from "../shared/utils"; import { createLogIntegrityService, type LogIntegrityService } from "./logIntegrityService"; type RepairOptions = { @@ -461,7 +463,69 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { deletedPaths.push(resolved); }; - if (options.packs) rmrf(repair.paths.artifactsDir); + if (options.packs) { + const artifactIdsUnderArtifactsDir = (() => { + try { + const rows = args.db.all<{ id: string; uri: string }>( + "select id, uri from computer_use_artifacts where project_id = ? and storage_kind = 'file'", + [args.projectId], + ); + return rows.flatMap((row) => { + const rawUri = typeof row.uri === "string" ? row.uri.trim() : ""; + if (!rawUri || /^https?:\/\//i.test(rawUri)) return []; + let candidate = rawUri; + try { + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(candidate)) { + candidate = decodeURIComponent(new URL(candidate).pathname.replace(/^\/+/, "")); + } else if (/^file:\/\//i.test(candidate)) { + candidate = fileURLToPath(candidate); + } + const absolute = path.resolve( + path.isAbsolute(candidate) ? candidate : path.join(args.projectRoot, candidate), + ); + resolvePathWithinRoot(repair.paths.artifactsDir, absolute, { allowMissing: true }); + return [row.id]; + } catch { + return []; + } + }); + } catch (error) { + args.logger?.warn("ade.project.clear_local_data.artifact_scope_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw new Error("Could not safely identify proof records stored under .ade/artifacts."); + } + })(); + rmrf(repair.paths.artifactsDir); + // Removing `.ade/artifacts` without the rows leaves every proof record + // pointing at bytes that no longer exist. Delete only records whose + // canonical file path was inside that removed root; attachment-backed + // records remain valid because `.ade/attachments` is managed separately. + if (artifactIdsUnderArtifactsDir.length) { + const placeholders = artifactIdsUnderArtifactsDir.map(() => "?").join(", "); + try { + args.db.run("begin immediate"); + args.db.run( + `delete from computer_use_artifact_links where artifact_id in (${placeholders})`, + artifactIdsUnderArtifactsDir, + ); + args.db.run( + `delete from computer_use_artifacts where project_id = ? and id in (${placeholders})`, + [args.projectId, ...artifactIdsUnderArtifactsDir], + ); + args.db.run("commit"); + } catch (error) { + try { + args.db.run("rollback"); + } catch { + // Surface the original row-cleanup failure. + } + args.logger?.warn("ade.project.clear_local_data.artifact_rows_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } if (options.logs) rmrf(repair.paths.logsDir); if (options.transcripts) rmrf(repair.paths.transcriptsDir); diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 313d9abfe..370741846 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -56,6 +56,7 @@ function makeProjectConfigDb() { setJson: vi.fn((key: string, value: unknown) => { store.set(key, value); }), + all: vi.fn(() => []), run: vi.fn(), } as any; } @@ -364,6 +365,50 @@ describe("createAdeProjectService.clearLocalData", () => { expect(fs.readFileSync(path.join(layout.cacheDir, "keep.json"), "utf8")).toBe("cache"); expect(fs.readFileSync(path.join(layout.secretsDir, "keep"), "utf8")).toBe("secret"); }); + + it("preserves attachment-backed proof rows when clearing artifact packs", async () => { + const root = makeTempDir("ade-project-clear-artifact-rows-"); + const layout = resolveAdeLayout(root); + const db = await openKvDb(path.join(layout.adeDir, "ade.db"), createLogger()); + const now = "2026-07-29T00:00:00.000Z"; + insertProject(db, "project-1", root, now); + const service = createAdeProjectService({ + projectRoot: root, + db, + projectId: "project-1", + logger: createLogger(), + projectConfigService: { + get: () => ({ validation: { ok: true, issues: [] } }), + }, + }); + const artifactFile = path.join(layout.artifactsDir, "computer-use", "proof.png"); + const attachmentFile = path.join(layout.adeDir, "attachments", "prompt.png"); + fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); + fs.mkdirSync(path.dirname(attachmentFile), { recursive: true }); + fs.writeFileSync(artifactFile, "proof"); + fs.writeFileSync(attachmentFile, "attachment"); + const insertArtifact = (id: string, uri: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'project-1', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', 'image/png', '{}', null, ?)`, + [id, id, uri, now], + ); + }; + insertArtifact("stored-proof", "ade-artifact://project/.ade/artifacts/computer-use/proof.png"); + insertArtifact("stored-attachment", ".ade/attachments/prompt.png"); + + service.clearLocalData({ packs: true }); + + expect(fs.existsSync(artifactFile)).toBe(false); + expect(fs.existsSync(attachmentFile)).toBe(true); + expect(db.all<{ id: string }>("select id from computer_use_artifacts order by id")).toEqual([ + { id: "stored-attachment" }, + ]); + db.close(); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts b/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts deleted file mode 100644 index d8cf33746..000000000 --- a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - loadAgentBrowserArtifactPayloadFromFile, - parseAgentBrowserArtifactPayload, -} from "./agentBrowserArtifactAdapter"; - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — null / undefined / non-record inputs -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — non-object inputs", () => { - it("returns an empty array for null", () => { - expect(parseAgentBrowserArtifactPayload(null)).toEqual([]); - }); - - it("returns an empty array for undefined", () => { - expect(parseAgentBrowserArtifactPayload(undefined)).toEqual([]); - }); - - it("returns an empty array for primitive strings", () => { - expect(parseAgentBrowserArtifactPayload("not-a-payload")).toEqual([]); - }); - - it("returns an empty array for numbers", () => { - expect(parseAgentBrowserArtifactPayload(42)).toEqual([]); - }); - - it("returns an empty array for booleans", () => { - expect(parseAgentBrowserArtifactPayload(true)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — array inputs -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — array inputs", () => { - it("coerces an array of records into artifact inputs", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { - kind: "screenshot", - title: "After click", - path: "/tmp/shot.png", - mimeType: "image/png", - }, - { - kind: "console_logs", - text: "log line", - }, - ]); - expect(inputs).toHaveLength(2); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "After click", - path: "/tmp/shot.png", - mimeType: "image/png", - }); - expect(inputs[1]).toMatchObject({ - kind: "console_logs", - text: "log line", - }); - }); - - it("skips non-record entries inside the array", () => { - const inputs = parseAgentBrowserArtifactPayload([ - null, - "string-entry", - 42, - { path: "/tmp/kept.png" }, - ]); - expect(inputs).toHaveLength(1); - expect(inputs[0]!.path).toBe("/tmp/kept.png"); - }); - - it("drops array entries with no path/uri/text/json", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { kind: "screenshot", title: "Just metadata" }, - { description: "only description" }, - ]); - expect(inputs).toEqual([]); - }); - - it("keeps an array entry that carries only json payload", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { kind: "browser_verification", json: { ok: true } }, - ]); - expect(inputs).toHaveLength(1); - expect(inputs[0]!.json).toEqual({ ok: true }); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — record.artifacts[] -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — record with artifacts[]", () => { - it("coerces each entry in the artifacts array", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { kind: "screenshot", path: "/tmp/a.png" }, - { kind: "console_logs", text: "error" }, - ], - }); - expect(inputs).toHaveLength(2); - expect(inputs[0]!.path).toBe("/tmp/a.png"); - expect(inputs[1]!.text).toBe("error"); - }); - - it("handles artifacts being undefined without throwing", () => { - const inputs = parseAgentBrowserArtifactPayload({ artifacts: undefined }); - expect(inputs).toEqual([]); - }); - - it("handles artifacts being a non-array value", () => { - const inputs = parseAgentBrowserArtifactPayload({ artifacts: "not-an-array" }); - expect(inputs).toEqual([]); - }); - - it("uses field aliases: type→kind, name→title, summary→description, filePath→path, url→uri, contentType→mimeType", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - type: "screenshot", - name: "Aliased entry", - summary: "summary-as-description", - filePath: "/tmp/alias.png", - url: "https://example.test/a.png", - contentType: "image/png", - }, - ], - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "Aliased entry", - description: "summary-as-description", - path: "/tmp/alias.png", - uri: "https://example.test/a.png", - mimeType: "image/png", - // rawType falls back to `type` when rawType is absent. - rawType: "screenshot", - }); - }); - - it("prefers canonical fields over aliases when both are present", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: "primary-kind", - type: "alias-kind", - title: "primary-title", - name: "alias-name", - path: "/primary/path.png", - filePath: "/alias/path.png", - uri: "https://primary.test/", - url: "https://alias.test/", - mimeType: "image/png", - contentType: "image/jpeg", - rawType: "primary-raw", - }, - ], - }); - expect(inputs[0]).toMatchObject({ - kind: "primary-kind", - title: "primary-title", - path: "/primary/path.png", - uri: "https://primary.test/", - mimeType: "image/png", - rawType: "primary-raw", - }); - }); - - it("treats whitespace-only strings as null", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: " ", - title: "\t\n ", - path: "/tmp/valid.png", - uri: " ", - text: " ", - }, - ], - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: null, - title: null, - path: "/tmp/valid.png", - uri: null, - text: null, - }); - }); - - it("trims surrounding whitespace from accepted strings", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: " screenshot ", - path: " /tmp/padded.png ", - }, - ], - }); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - path: "/tmp/padded.png", - }); - }); - - it("preserves record metadata when present and drops non-record metadata", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { path: "/tmp/has-meta.png", metadata: { region: "checkout" } }, - { path: "/tmp/no-meta.png", metadata: "stringly" }, - ], - }); - expect(inputs).toHaveLength(2); - expect(inputs[0]!.metadata).toEqual({ region: "checkout" }); - expect(inputs[1]!.metadata).toBeNull(); - }); - - it("drops entries that have no path/uri/text/json even with a kind or title", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { kind: "screenshot", title: "metadata-only" }, - { description: "no payload" }, - ], - }); - expect(inputs).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — direct path mapping fields -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — direct path mappings", () => { - it("maps screenshotPath to a screenshot input with sourceField metadata", () => { - const inputs = parseAgentBrowserArtifactPayload({ - screenshotPath: "/tmp/ss.png", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "Agent-browser screenshot", - path: "/tmp/ss.png", - rawType: "screenshotPath", - metadata: { sourceField: "screenshotPath" }, - }); - }); - - it("maps imagePath, videoPath, tracePath, consoleLogsPath, consoleLogPath, verificationPath", () => { - const inputs = parseAgentBrowserArtifactPayload({ - imagePath: "/tmp/image.png", - videoPath: "/tmp/rec.mp4", - tracePath: "/tmp/trace.zip", - consoleLogsPath: "/tmp/logs1.txt", - consoleLogPath: "/tmp/logs2.txt", - verificationPath: "/tmp/verify.json", - }); - const byField = new Map(inputs.map((input) => [input.rawType, input])); - expect(byField.get("imagePath")).toMatchObject({ kind: "screenshot", path: "/tmp/image.png" }); - expect(byField.get("videoPath")).toMatchObject({ kind: "video_recording", path: "/tmp/rec.mp4" }); - expect(byField.get("tracePath")).toMatchObject({ kind: "browser_trace", path: "/tmp/trace.zip" }); - expect(byField.get("consoleLogsPath")).toMatchObject({ kind: "console_logs", path: "/tmp/logs1.txt" }); - expect(byField.get("consoleLogPath")).toMatchObject({ kind: "console_logs", path: "/tmp/logs2.txt" }); - expect(byField.get("verificationPath")).toMatchObject({ kind: "browser_verification", path: "/tmp/verify.json" }); - expect(inputs).toHaveLength(6); - }); - - it("skips direct mapping fields that are empty or whitespace-only", () => { - const inputs = parseAgentBrowserArtifactPayload({ - screenshotPath: "", - videoPath: " ", - tracePath: null, - verificationPath: "/tmp/verify.json", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "browser_verification", - path: "/tmp/verify.json", - }); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — direct text mapping fields -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — direct text mappings", () => { - it("maps consoleLogs, consoleLog, and verificationText to text inputs", () => { - const inputs = parseAgentBrowserArtifactPayload({ - consoleLogs: "plural logs", - consoleLog: "singular log", - verificationText: "ok", - }); - const byField = new Map(inputs.map((input) => [input.rawType, input])); - expect(byField.get("consoleLogs")).toMatchObject({ - kind: "console_logs", - title: "Agent-browser console logs", - text: "plural logs", - metadata: { sourceField: "consoleLogs" }, - }); - expect(byField.get("consoleLog")).toMatchObject({ - kind: "console_logs", - text: "singular log", - }); - expect(byField.get("verificationText")).toMatchObject({ - kind: "browser_verification", - text: "ok", - }); - expect(inputs).toHaveLength(3); - }); - - it("skips direct text fields that are empty or whitespace-only", () => { - const inputs = parseAgentBrowserArtifactPayload({ - consoleLogs: "", - consoleLog: " \n\t", - verificationText: "verified", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "browser_verification", - text: "verified", - }); - }); - - it("combines artifacts[], direct path mappings, and direct text mappings", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [{ kind: "screenshot", path: "/tmp/from-array.png" }], - screenshotPath: "/tmp/from-direct.png", - consoleLogs: "direct log", - }); - expect(inputs).toHaveLength(3); - const paths = inputs.map((input) => input.path); - expect(paths).toContain("/tmp/from-array.png"); - expect(paths).toContain("/tmp/from-direct.png"); - const textEntries = inputs.filter((input) => input.text); - expect(textEntries).toHaveLength(1); - expect(textEntries[0]!.text).toBe("direct log"); - }); -}); - -// --------------------------------------------------------------------------- -// loadAgentBrowserArtifactPayloadFromFile -// --------------------------------------------------------------------------- - -describe("loadAgentBrowserArtifactPayloadFromFile", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-agent-browser-adapter-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("reads a JSON file and parses the payload", () => { - const filePath = path.join(tmpDir, "payload.json"); - fs.writeFileSync( - filePath, - JSON.stringify({ - screenshotPath: "/tmp/shot.png", - verificationText: "verified", - }), - "utf8", - ); - const inputs = loadAgentBrowserArtifactPayloadFromFile(filePath); - expect(inputs).toHaveLength(2); - const kinds = inputs.map((input) => input.kind).sort(); - expect(kinds).toEqual(["browser_verification", "screenshot"]); - }); - - it("parses array-at-root JSON files", () => { - const filePath = path.join(tmpDir, "array.json"); - fs.writeFileSync( - filePath, - JSON.stringify([ - { kind: "screenshot", path: "/tmp/a.png" }, - { kind: "console_logs", text: "line" }, - ]), - "utf8", - ); - const inputs = loadAgentBrowserArtifactPayloadFromFile(filePath); - expect(inputs).toHaveLength(2); - }); - - it("returns an empty array when the file contains JSON null", () => { - const filePath = path.join(tmpDir, "null.json"); - fs.writeFileSync(filePath, "null", "utf8"); - expect(loadAgentBrowserArtifactPayloadFromFile(filePath)).toEqual([]); - }); - - it("throws a SyntaxError when the file contains invalid JSON", () => { - const filePath = path.join(tmpDir, "bad.json"); - fs.writeFileSync(filePath, "{ not valid json", "utf8"); - expect(() => loadAgentBrowserArtifactPayloadFromFile(filePath)).toThrow(SyntaxError); - }); - - it("throws when the file does not exist", () => { - const filePath = path.join(tmpDir, "missing.json"); - expect(() => loadAgentBrowserArtifactPayloadFromFile(filePath)).toThrow(/ENOENT|no such file/i); - }); -}); diff --git a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts b/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts deleted file mode 100644 index 3d784a7ce..000000000 --- a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from "node:fs"; -import type { ComputerUseArtifactInput } from "../../../shared/types"; -import { isRecord } from "../shared/utils"; - -function toOptionalString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function pushInput(target: ComputerUseArtifactInput[], input: ComputerUseArtifactInput | null): void { - if (!input) return; - if (!input.path && !input.uri && !input.text && input.json == null) return; - target.push(input); -} - -function coerceArtifactEntry(entry: unknown): ComputerUseArtifactInput | null { - if (!isRecord(entry)) return null; - return { - kind: toOptionalString(entry.kind) ?? toOptionalString(entry.type), - title: toOptionalString(entry.title) ?? toOptionalString(entry.name), - description: toOptionalString(entry.description) ?? toOptionalString(entry.summary), - path: toOptionalString(entry.path) ?? toOptionalString(entry.filePath), - uri: toOptionalString(entry.uri) ?? toOptionalString(entry.url), - text: toOptionalString(entry.text), - json: entry.json, - mimeType: toOptionalString(entry.mimeType) ?? toOptionalString(entry.contentType), - rawType: toOptionalString(entry.rawType) ?? toOptionalString(entry.type), - metadata: isRecord(entry.metadata) ? entry.metadata : null, - }; -} - -export function parseAgentBrowserArtifactPayload(payload: unknown): ComputerUseArtifactInput[] { - const inputs: ComputerUseArtifactInput[] = []; - if (!payload) return inputs; - - if (Array.isArray(payload)) { - for (const entry of payload) { - pushInput(inputs, coerceArtifactEntry(entry)); - } - return inputs; - } - - if (!isRecord(payload)) return inputs; - - if (Array.isArray(payload.artifacts)) { - for (const entry of payload.artifacts) { - pushInput(inputs, coerceArtifactEntry(entry)); - } - } - - const directMappings: Array<[string, string, string]> = [ - ["screenshotPath", "screenshot", "Agent-browser screenshot"], - ["imagePath", "screenshot", "Agent-browser screenshot"], - ["videoPath", "video_recording", "Agent-browser video"], - ["tracePath", "browser_trace", "Agent-browser trace"], - ["consoleLogsPath", "console_logs", "Agent-browser console logs"], - ["consoleLogPath", "console_logs", "Agent-browser console logs"], - ["verificationPath", "browser_verification", "Agent-browser verification"], - ]; - for (const [field, kind, title] of directMappings) { - const pathValue = toOptionalString(payload[field]); - if (!pathValue) continue; - pushInput(inputs, { - kind, - title, - path: pathValue, - rawType: field, - metadata: { sourceField: field }, - }); - } - - const directTextMappings: Array<[string, string, string]> = [ - ["consoleLogs", "console_logs", "Agent-browser console logs"], - ["consoleLog", "console_logs", "Agent-browser console logs"], - ["verificationText", "browser_verification", "Agent-browser verification"], - ]; - for (const [field, kind, title] of directTextMappings) { - const textValue = toOptionalString(payload[field]); - if (!textValue) continue; - pushInput(inputs, { - kind, - title, - text: textValue, - rawType: field, - metadata: { sourceField: field }, - }); - } - - return inputs; -} - -export function loadAgentBrowserArtifactPayloadFromFile(filePath: string): ComputerUseArtifactInput[] { - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - return parseAgentBrowserArtifactPayload(parsed); -} diff --git a/apps/desktop/src/main/services/prs/prChatCards.test.ts b/apps/desktop/src/main/services/prs/prChatCards.test.ts index 53b525faf..1ec194d77 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.test.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.test.ts @@ -227,6 +227,91 @@ describe("PR chat cards", () => { }); }); + // `total === 0` had no coverage at all, and it is the branch that rendered a + // content-free `[passing status]` chip under a green check while GitHub was + // returning 403 — the two zero-count cases are not the same fact. + it("reports a genuinely empty check set as a plain status, not as degraded", () => { + const card = buildPrCiCard({ pr: pr({ checksStatus: "passing" }), runs: [], checks: [] }); + expect(card.degradedReason).toBeUndefined(); + expect(card.actions).toBeUndefined(); + expect(card.metrics).toEqual([{ label: "status", value: "passing", tone: "success" }]); + }); + + it("says the detail is unavailable — never a false green — when the fetch failed", () => { + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [], + checks: [], + fetchError: "HTTP 403: API rate limit exceeded", + }); + expect(card.degradedReason).toContain("403"); + expect(card.metrics).toEqual([]); + expect(card.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); + expect(card.fallbackText).toContain("detail unavailable"); + }); + + it("keeps partial rows while marking a one-endpoint failure as degraded", () => { + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [], + checks: [check("Vercel")], + fetchError: "HTTP 403: API rate limit exceeded", + }); + expect(card.degradedReason).toContain("403"); + expect(card.rows?.[0]?.text).toBe("Vercel"); + expect(card.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); + expect(card.fallbackText).toContain("job detail unavailable in part"); + }); + + it("counts the rows it dropped instead of silently capping at three", () => { + const jobs = ["a", "b", "c", "d", "e"].map((name, index) => ({ + id: index + 1, + name, + status: "completed" as const, + conclusion: "success" as const, + startedAt: null, + completedAt: null, + htmlUrl: null, + steps: [], + })); + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [run({ status: "completed", conclusion: "success", jobs })], + checks: [], + }); + expect(card.rows).toHaveLength(3); + expect(card.rowsTruncated).toBe(2); + }); + + it("surfaces the fetch failure instead of swallowing it into an empty array", async () => { + const emitAdeCard = vi.fn().mockResolvedValue(undefined); + await emitPrCardsForChange({ + change: { + pr: pr({ checksStatus: "passing" }), + previousState: "open", + previousChecksStatus: "pending", + previousReviewStatus: "requested", + previousMergeConflicts: false, + previousBehindBaseBy: 0, + }, + dataSource: { + getActionRuns: vi.fn().mockRejectedValue(new Error("HTTP 403: rate limited")), + getChecks: vi.fn().mockRejectedValue(new Error("HTTP 403: rate limited")), + getReviews: vi.fn().mockResolvedValue([]), + getReviewThreads: vi.fn().mockResolvedValue([]), + }, + chat: { + listSessions: vi.fn().mockResolvedValue([session("newer", "2026-07-27T12:00:00.000Z")]), + emitAdeCard, + }, + }); + + const ciCard = emitAdeCard.mock.calls + .map(([call]) => call.card) + .find((card) => card.variant === "pr_ci"); + expect(ciCard?.degradedReason).toContain("403"); + }); + it("bounds long review bodies before persisting them into a chat transcript", () => { const card = buildPrReviewCard({ pr: pr({ reviewStatus: "changes_requested" }), diff --git a/apps/desktop/src/main/services/prs/prChatCards.ts b/apps/desktop/src/main/services/prs/prChatCards.ts index 44dd18f71..3e7942cb7 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.ts @@ -95,10 +95,14 @@ function compactCardText(value: string, maxChars = 480): string { return compact.length <= maxChars ? compact : `${compact.slice(0, maxChars - 1).trimEnd()}…`; } +/** Rows a CI card shows before it starts counting. Kept small; the rest is `+N more`. */ +const CI_ROW_CAP = 3; + function ciRows(runs: PrActionRun[], checks: PrCheck[]): { rows: AdeCardRow[]; progress: AdeCardProgress; other: number; + truncated: number; } { const jobs = runs.flatMap((run) => run.jobs); const items: Array = jobs.length > 0 ? jobs : checks; @@ -121,7 +125,8 @@ function ciRows(runs: PrActionRun[], checks: PrCheck[]): { return { progress, other: ranked.filter((entry) => entry.bucket === "skipped" || entry.bucket === "unknown").length, - rows: ranked.slice(0, 3).map(({ item, bucket }) => ({ + truncated: Math.max(0, ranked.length - CI_ROW_CAP), + rows: ranked.slice(0, CI_ROW_CAP).map(({ item, bucket }) => ({ icon: jobIcon(bucket), text: item.name, detail: bucket, @@ -151,8 +156,19 @@ export function buildPrCiCard(args: { pr: PrSummary; runs: PrActionRun[]; checks: PrCheck[]; + /** + * Why the job/check fetch produced nothing, when it failed. + * + * `[]` from a rejected GitHub call and `[]` from a PR that genuinely has no + * jobs are the same value but not the same fact: swallowing the difference + * rendered a content-free `[passing status]` chip under a green check while + * GitHub was returning 403. When this is set, the card says so and offers a + * retry instead of claiming a clean run. + */ + fetchError?: string | null; }): AdeCardPayload { const { pr, runs, checks } = args; + const fetchError = args.fetchError?.trim() || null; const latestRuns = latestRunsByWorkflow(runs); const run = newestRun(latestRuns); const attempt = Math.max(1, ...latestRuns.map((entry) => entry.runAttempt ?? 1)); @@ -160,7 +176,7 @@ export function buildPrCiCard(args: { || run?.headSha?.trim() || `${pr.githubPrNumber}:unknown-head`; const episode = `${episodeHead}:${attempt}`; - const { rows, progress, other } = ciRows(latestRuns, checks); + const { rows, progress, other, truncated } = ciRows(latestRuns, checks); const state = pr.checksStatus === "pending" ? "live" : "terminal"; const title = pr.checksStatus === "passing" ? "CI passed" @@ -173,6 +189,10 @@ export function buildPrCiCard(args: { ? "success" : "accent"; const total = progress.passed + progress.failed + progress.running + progress.queued + other; + // A partial response is still degraded: the surviving endpoint's rows remain + // useful, but they are not the complete job inventory. Keep the warning and + // Retry action alongside those rows instead of presenting them as complete. + const degraded = fetchError != null; return { cardId: `pr-ci:${pr.id}:${episode}`, @@ -187,11 +207,22 @@ export function buildPrCiCard(args: { { label: "active", value: String(progress.running + progress.queued), tone: "accent" }, ...(other > 0 ? [{ label: "other", value: String(other), tone: "neutral" as const }] : []), ] - : [{ label: "status", value: pr.checksStatus, tone }], + : degraded + ? [] + : [{ label: "status", value: pr.checksStatus, tone }], rows, progress, + ...(truncated > 0 ? { rowsTruncated: truncated } : {}), + ...(degraded + ? { + degradedReason: `Couldn’t read the job list from GitHub — ${fetchError}`, + actions: [{ id: "retry", label: "Retry", kind: "primary" as const }], + } + : {}), navTarget: prNavTarget(pr, "checks"), - fallbackText: `PR #${pr.githubPrNumber} ${title.toLowerCase()}.`, + fallbackText: degraded + ? `PR #${pr.githubPrNumber} checks are ${pr.checksStatus}; job detail unavailable in part (${fetchError}).` + : `PR #${pr.githubPrNumber} ${title.toLowerCase()}.`, }; } @@ -353,11 +384,20 @@ export async function emitPrCardsForChange(args: { const cards: AdeCardPayload[] = []; if (checksChanged) { - const [runs, checks] = await Promise.all([ - dataSource.getActionRuns(pr.id).catch(() => []), - dataSource.getChecks(pr.id).catch(() => []), + // Capture the failures instead of `.catch(() => [])`-ing them away: a + // rate-limited GitHub call and a PR with no jobs both produce `[]`, and + // only one of them means "everything is fine". + const [runsResult, checksResult] = await Promise.allSettled([ + dataSource.getActionRuns(pr.id), + dataSource.getChecks(pr.id), ]); - cards.push(buildPrCiCard({ pr, runs, checks })); + const runs = runsResult.status === "fulfilled" ? runsResult.value : []; + const checks = checksResult.status === "fulfilled" ? checksResult.value : []; + const fetchError = [runsResult, checksResult] + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason))) + .join("; ") || null; + cards.push(buildPrCiCard({ pr, runs, checks, fetchError })); } if (reviewChanged) { const [reviews, threads] = await Promise.all([ diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 5d73a68a6..0b0fee350 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2866,6 +2866,13 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { `); db.run("create index if not exists idx_computer_use_artifacts_project_created on computer_use_artifacts(project_id, created_at)"); db.run("create index if not exists idx_computer_use_artifacts_project_kind on computer_use_artifacts(project_id, artifact_kind)"); + // Lane a capture belongs to, so deleting a lane can delete its proof instead + // of orphaning rows behind a removed worktree. Nullable and backfill-free: + // pre-existing rows keep `null` ("unknown lane") and are only reachable + // through the explicit prune/delete paths. Non-unique index only — a CRR + // table cannot carry a UNIQUE index besides its primary key. + safeAddColumn(db, "alter table computer_use_artifacts add column lane_id text"); + db.run("create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id)"); db.run(` create table if not exists computer_use_artifact_links ( diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index 9c754be92..2039ac4e4 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -15,6 +15,7 @@ import { STORAGE_LEDGER, } from "./storageLedger"; import { recordLastFailure } from "../runtime/lastFailureStore"; +import { createPromptStash } from "../chat/promptStashService"; const logger = { debug: vi.fn(), @@ -120,7 +121,7 @@ describe("storageInsightsService", () => { writeSized(path.join(projectRoot, ".ade", "transcripts", "chat", "one.log"), 11); writeSized(path.join(projectRoot, ".ade", "cache", "terminal-snapshots", "one.txt"), 13); writeSized(path.join(projectRoot, ".ade", "cache", "browser-observations", "cache.bin"), 7); - writeSized(path.join(projectRoot, ".ade", "artifacts", "proof.bin"), 5); + writeSized(path.join(projectRoot, ".ade", "artifacts", "computer-use", "proof.bin"), 5); writeSized(path.join(projectRoot, ".ade", "ade.db.recovery-fixture.bak"), 3); const lanePath = path.join(projectRoot, ".ade", "worktrees", "active-lane"); writeSized(path.join(lanePath, "source.bin"), 17); @@ -355,6 +356,99 @@ describe("storageInsightsService", () => { expect((await capped.getSnapshot()).truncated).toBe(true); }); + it("removes proof storage and drops the matching proof records with it", async () => { + // Settings used to render this card with a size and no Remove button, + // pointing the user at a proof-drawer control that did not exist. + const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); + const proofDir = path.join(artifactsDir, "computer-use"); + const packsDir = path.join(artifactsDir, "packs"); + const logBundlesDir = path.join(artifactsDir, "log-bundles"); + writeSized(path.join(proofDir, "shot.png"), 21); + writeSized(path.join(packsDir, "lane-pack.tar"), 13); + writeSized(path.join(logBundlesDir, "diagnostic.log"), 8); + const purged: string[] = []; + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + purgeProofRecordsUnder: (removedPath) => purged.push(removedPath), + }); + + const targets: StorageCleanupTarget[] = [{ kind: "proof_attachments", path: proofDir }]; + const preview = await service.cleanupPreview(targets); + expect(preview.blocked).toEqual([]); + expect(preview.items[0]).toMatchObject({ path: proofDir, bytes: 21, label: "Proof and recordings" }); + + const result = await service.cleanup(targets, { preview }); + expect(result.removed).toEqual([{ path: proofDir, bytes: 21 }]); + expect(fs.existsSync(proofDir)).toBe(false); + expect(fs.existsSync(path.join(packsDir, "lane-pack.tar"))).toBe(true); + expect(fs.existsSync(path.join(logBundlesDir, "diagnostic.log"))).toBe(true); + // Rows must never outlive the bytes. + expect(purged).toEqual([proofDir]); + }); + + it("never offers the live composer attachment store for recursive cleanup", async () => { + const attachmentsDir = path.join(projectRoot, ".ade", "attachments"); + const stashedImage = path.join(attachmentsDir, "stashed-image.png"); + const activeDraftImage = path.join(attachmentsDir, "active-draft.png"); + writeSized(stashedImage, 17); + writeSized(activeDraftImage, 19); + createPromptStash(db, { + text: "Keep this image", + attachments: [{ path: stashedImage, type: "image" }], + }); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const snapshot = await service.getSnapshot(); + expect(snapshot.categories.find((category) => category.id === "proof_attachments")?.items) + .not.toContainEqual(expect.objectContaining({ path: attachmentsDir })); + + const preview = await service.cleanupPreview([ + { kind: "proof_attachments", path: attachmentsDir }, + { kind: "proof_attachments", path: activeDraftImage }, + ]); + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { + path: attachmentsDir, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }, + { + path: activeDraftImage, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }, + ]); + expect(fs.existsSync(stashedImage)).toBe(true); + expect(fs.existsSync(activeDraftImage)).toBe(true); + }); + + it("refuses proof cleanup for the shared artifacts parent", async () => { + const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); + writeSized(path.join(artifactsDir, "packs", "lane-pack.tar"), 4); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const preview = await service.cleanupPreview([{ kind: "proof_attachments", path: artifactsDir }]); + + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { path: artifactsDir, reason: "This path is not proof or attachment storage." }, + ]); + }); + + it("refuses proof cleanup for paths outside the proof and attachment stores", async () => { + const outside = path.join(projectRoot, ".ade", "cache"); + writeSized(path.join(outside, "blob.bin"), 4); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const preview = await service.cleanupPreview([{ kind: "proof_attachments", path: outside }]); + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { path: outside, reason: "This path is not proof or attachment storage." }, + ]); + }); + it("itemizes a removed target and a target deleted after preview", async () => { const first = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-old-first-")); const second = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-old-second-")); diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index f61edcaf2..09fbffdc1 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -141,6 +141,13 @@ export type StorageInsightsServiceOptions = { laneService?: LaneLifecycleBackend | null; projectConfigService?: LaneCleanupConfigReader | null; releaseLaneRuntimeResources?: ((laneId: string) => void | Promise) | null; + /** + * Drops the proof records whose stored file lived at or under a removed path. + * Injected rather than imported so this service keeps its single dependency + * on the filesystem; absent in the daemon-backed fallback instance, where the + * broker lives in the other process and prunes on its own next read. + */ + purgeProofRecordsUnder?: (removedPath: string) => void; }; export function isObsoleteRecoveryBackup( @@ -798,19 +805,15 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti }))?.item); } - for (const [proofPath, label] of [ - [layout.artifactsDir, "Proof and recordings"], - [path.join(layout.adeDir, "attachments"), "Attachments"], - ] as const) { - add("proof_attachments", (await makeItem({ - category: "proof_attachments", - base: layout.adeDir, - path: proofPath, - label, - safety: "review_first", - state, - }))?.item); - } + const proofPath = path.join(layout.artifactsDir, "computer-use"); + add("proof_attachments", (await makeItem({ + category: "proof_attachments", + base: layout.adeDir, + path: proofPath, + label: "Proof and recordings", + safety: "review_first", + state, + }))?.item); const adeNames = await readdirOrEmpty(layout.adeDir); for (const name of adeNames.filter((value) => RECOVERY_BACKUP_PATTERN.test(value))) { @@ -1016,6 +1019,26 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } if (!stat.isFile()) return { valid: null, reason: "Recovery backups must be files." }; label = "Recovery backup"; + } else if (target.kind === "proof_attachments") { + if (await hasSymlinkAncestor(projectRoot, targetPath)) { + return { valid: null, reason: "Links cannot be used in a cleanup path." }; + } + const attachmentsRoot = path.join(layout.adeDir, "attachments"); + if (isSameOrWithin(attachmentsRoot, targetPath)) { + // This store backs prompt stashes and renderer-owned composer drafts. + // Main cannot enumerate every active draft, so recursive or per-file + // cleanup here could delete live user input. + return { + valid: null, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }; + } + // Same jail the broker and the `ade-artifact://` handler enforce. + const proofRoot = path.join(layout.artifactsDir, "computer-use"); + if (!isSameOrWithin(proofRoot, targetPath)) { + return { valid: null, reason: "This path is not proof or attachment storage." }; + } + label = "Proof and recordings"; } else { return { valid: null, reason: "This cleanup target is not supported." }; } @@ -1123,6 +1146,18 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } else { await fs.promises.rm(checked.valid.path, { recursive: true, force: false }); } + if (target.kind === "proof_attachments") { + // Rows must not outlive the bytes: a proof record whose file is gone + // renders as a permanently broken tile in the drawer. + try { + options.purgeProofRecordsUnder?.(checked.valid.path); + } catch (error) { + options.logger.warn("storage.cleanup_proof_records_failed", { + path: checked.valid.path, + error: error instanceof Error ? error.message : String(error), + }); + } + } removed.push({ path: checked.valid.path, bytes: checked.valid.bytes }); } catch (error) { failed.push({ path: rawPath, reason: error instanceof Error ? error.message : String(error) }); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 6cb55e03e..d809f8361 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -570,9 +570,11 @@ import type { RunHealthCheckArgs, ActivateFallbackArgs, DeactivateFallbackArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, ComputerUseArtifactView, ComputerUseEventPayload, ComputerUseOwnerSnapshot, @@ -1788,9 +1790,16 @@ declare global { getOwnerSnapshot: ( args: ComputerUseOwnerSnapshotArgs, ) => Promise; - routeArtifact: ( - args: ComputerUseArtifactRouteArgs, - ) => Promise; + deleteArtifacts: ( + args: ComputerUseArtifactDeleteArgs, + ) => Promise; + listBrokenArtifacts: (args?: { + limit?: number; + }) => Promise; + pruneBrokenArtifacts: () => Promise; + recoverArtifact: (args: { + artifactId: string; + }) => Promise; updateArtifactReview: ( args: ComputerUseArtifactReviewArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 90fe17394..37281f6dc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -611,7 +611,9 @@ import type { DeactivateFallbackArgs, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactView, ComputerUseEventPayload, ComputerUseOwnerSnapshot, @@ -6612,17 +6614,50 @@ contextBridge.exposeInMainWorld("ade", { args: ComputerUseOwnerSnapshotArgs, ): Promise => computerUseOwnerSnapshotCache.get(serializeIpcCacheArgs(args)), - routeArtifact: async ( - args: ComputerUseArtifactRouteArgs, + deleteArtifacts: async ( + args: ComputerUseArtifactDeleteArgs, + ): Promise => + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "deleteArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseDeleteArtifacts, args), + ), + ), + listBrokenArtifacts: async ( + args: { limit?: number } = {}, + ): Promise => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "listBrokenArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseListBrokenArtifacts, args), + ), + pruneBrokenArtifacts: async (): Promise => + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "pruneBrokenArtifacts", + { args: {} }, + () => ipcRenderer.invoke(IPC.computerUsePruneBrokenArtifacts), + ), + ), + recoverArtifact: async ( + args: { artifactId: string }, ): Promise => clearAround( () => computerUseOwnerSnapshotCache.clear(), () => callProjectRuntimeActionOr( "computer_use_artifacts", - "routeArtifact", + "recoverArtifact", { args }, - () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + () => ipcRenderer.invoke(IPC.computerUseRecoverArtifact, args), ), ), updateArtifactReview: async ( diff --git a/apps/desktop/src/renderer/browserMock.test.ts b/apps/desktop/src/renderer/browserMock.test.ts index 178fd2211..7c9ef5788 100644 --- a/apps/desktop/src/renderer/browserMock.test.ts +++ b/apps/desktop/src/renderer/browserMock.test.ts @@ -62,3 +62,27 @@ describe("browserMock prompt stashes", () => { await expect(window.ade.agentChat.promptStashes.list()).resolves.not.toContainEqual(created); }); }); + +describe("browserMock proof contracts", () => { + it("returns complete delete, prune, and recovery results", async () => { + await expect(window.ade.computerUse.deleteArtifacts({ artifactId: "proof-1" })).resolves.toEqual({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + }); + await expect(window.ade.computerUse.pruneBrokenArtifacts()).resolves.toEqual({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + }); + await expect(window.ade.computerUse.recoverArtifact({ artifactId: "proof-1" })).resolves.toMatchObject({ + id: "browser-proof-recovered", + availability: "available", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + }); + }); +}); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 695c2ed05..5ffd9db35 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3751,9 +3751,34 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { computerUse: { listArtifacts: resolvedArg([]), getOwnerSnapshot: resolvedArg({} as any), - routeArtifact: resolvedArg({} as any), updateArtifactReview: resolvedArg({} as any), readArtifactPreview: resolvedArg(null), + // The drawer calls these directly; without them the standalone web + // renderer throws a TypeError on the delete and recover controls. + deleteArtifacts: resolvedArg({ deleted: [], missing: [], failed: [], freedBytes: 0 }), + listBrokenArtifacts: resolvedArg([]), + pruneBrokenArtifacts: resolvedArg({ deleted: [], missing: [], failed: [], freedBytes: 0 }), + recoverArtifact: resolvedArg({ + id: "browser-proof-recovered", + kind: "screenshot", + backendStyle: "local_fallback", + backendName: "ADE browser preview", + sourceToolName: "recover", + originalType: "image", + title: "Recovered proof", + description: null, + uri: ".ade/artifacts/browser-proof-recovered.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + laneId: null, + createdAt: "2026-01-01T00:00:00.000Z", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + }), onEvent: () => () => {}, }, onboarding: { diff --git a/apps/desktop/src/renderer/components/chat/AdeCard.tsx b/apps/desktop/src/renderer/components/chat/AdeCard.tsx index 46ce938a6..cda36fe2c 100644 --- a/apps/desktop/src/renderer/components/chat/AdeCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AdeCard.tsx @@ -2,19 +2,30 @@ import { useEffect, useState } from "react"; import { ArrowSquareOut, CaretRight, - CheckCircle, - Circle, - DotsThree, - File as FileIcon, - Info, - SpinnerGap, - XCircle, + Cube, + GitMerge, + Warning, + type Icon as PhosphorIcon, } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { formatSubagentDurationMs } from "../../lib/format"; import { navigateToAppTarget } from "../../lib/openExternal"; import { CHAT_TRANSCRIPT_GLASS_CARD_CLASS } from "./chatTranscriptChrome"; -import { chatChipToneClass } from "./chatSurfaceTheme"; +import { + CHAT_CARD_MICRO_TEXT, + CHAT_CARD_WIDTH_CLASS, + ChatCard, + ChatCardButton, + ChatCardChip, + ChatCardDetail, + ChatCardDetailRow, + ChatCardDiffStat, + ChatCardMeter, + ChatCardRow, + ChatCardSub, + ChatCardTitle, + type ChatCardTone, +} from "./chatCardPrimitives"; import { adeCardDeeplink, adeCardFallbackText, @@ -23,68 +34,86 @@ import { normalizeAdeCardTone, type AdeCardIcon, type AdeCardPayload, - type AdeCardTone, } from "../../../shared/adeCard"; -import type { ChatSurfaceChipTone } from "../../../shared/types"; /** * The `ade_card` renderer. * - * Structurally and stylistically a sibling of `SubagentActivityCards.tsx` — same - * `max-w-[min(100%,70ch)]` shell, same `calc(var(--chat-radius-card)-6px)` radius, - * same `--chat-font-size`-relative type scale, same one-line ` · `-joined status, - * same whole-card-clickable-with-identical-inner-markup pattern. It also inherits - * that file's tone policy: THERE IS NO RED HERE. A failed thing is amber, and the - * detail lives in the row's `detail` column rather than a red error block. + * Built entirely from `./chatCardPrimitives` — the `[glyph | content | meta]` + * grid, `CHAT_CARD_WIDTH_CLASS`, and the shared tone vocabulary — so an + * `ade_card` lines up column-for-column with every other transcript row rather + * than inventing its own indent and its own width. + * + * Shape follows the state, not the variant name: + * + * - a green/quiet result is ONE line (no box, hairline rule); + * - a failure gets an amber rail and lists only the rows that failed; + * - a live card gets an inset box and a progress meter. * - * A variant this build does not recognize renders `fallbackText` + the deeplink - * instead of nothing — that degradation is the reason one wire contract can ship - * across three independent release trains. + * Tone policy inherited from `shared/adeCard.ts`: THERE IS NO RED HERE. A failed + * thing is amber, and its detail lives in the row's detail column rather than a + * red error block. A variant this build does not recognize renders + * `fallbackText` + the deeplink instead of nothing — that degradation is the + * reason one wire contract can ship across three independent release trains. */ -const TONE_TEXT_CLASS: Record = { - neutral: "text-fg/45", - accent: "text-[color:var(--chat-accent)]", - success: "text-emerald-300/80", - warning: "text-amber-200/85", -}; - -const TONE_CHIP_TONE: Record = { - neutral: "muted", - accent: "accent", - success: "success", - warning: "warning", -}; - -/** Progress-bar segment fills. Failure is amber, per the house policy above. */ -const PROGRESS_SEGMENT_CLASS: Record<"passed" | "failed" | "running" | "queued", string> = { - passed: "bg-emerald-400/70", - failed: "bg-amber-400/80", - running: "bg-[color:var(--chat-accent)]", - queued: "bg-white/12", -}; - -function RowGlyph({ icon, tone }: { icon: AdeCardIcon | undefined; tone: AdeCardTone }) { - const className = cn("shrink-0", TONE_TEXT_CLASS[tone]); +/** Semantic row glyph → the shared tone vocabulary. */ +function rowToneForIcon(icon: AdeCardIcon | undefined, declared: ChatCardTone): ChatCardTone { switch (icon) { case "pass": - return ; + return "ok"; case "fail": - return ; + return "warn"; case "running": - return ; - case "queued": - return ; + return "running"; case "skipped": - return ; + return "idle"; + case "queued": case "file": - return ; case "info": default: - return ; + return declared; + } +} + +function toneOf(value: string | null | undefined): ChatCardTone { + switch (normalizeAdeCardTone(value)) { + case "success": + return "ok"; + case "warning": + return "warn"; + case "accent": + return "running"; + default: + return "neutral"; } } +/** Head glyph per variant family. Everything else falls back to the tone glyph. */ +function variantIcon(variant: string): PhosphorIcon | undefined { + if (variant === "pr_merged" || variant === "pr_merge_ready") return GitMerge; + if (variant === "pr_conflict") return Warning; + if (variant === "proof_artifact") return Cube; + return undefined; +} + +/** + * `+23,574 / −912` when the emitter shipped additions/deletions metrics, so a PR + * card can be one line with the diff in the meta column instead of two chips. + */ +function diffStatFromMetrics(card: AdeCardPayload): { additions: number; deletions: number } | null { + const read = (label: string): number | null => { + const metric = (card.metrics ?? []).find((entry) => entry.label === label); + if (!metric) return null; + const parsed = Number.parseInt(metric.value.replace(/[^0-9]/g, ""), 10); + return Number.isFinite(parsed) ? parsed : null; + }; + const additions = read("additions"); + const deletions = read("deletions"); + if (additions == null && deletions == null) return null; + return { additions: additions ?? 0, deletions: deletions ?? 0 }; +} + function liveElapsedText(startedAt: string | null | undefined, nowMs: number): string | null { if (!startedAt) return null; const start = Date.parse(startedAt); @@ -92,6 +121,20 @@ function liveElapsedText(startedAt: string | null | undefined, nowMs: number): s return formatSubagentDurationMs(Math.max(0, nowMs - start)); } +/** + * The span between the card's first and last emit. NOT a run duration — a CI + * card that is re-polled for three hours has a three-hour span and a three- + * minute run. Labelled `tracked` wherever it appears so the two never read as + * the same number. + */ +function trackedSpanText(card: AdeCardPayload): string | null { + if (!card.createdAt || !card.updatedAt) return null; + const span = Date.parse(card.updatedAt) - Date.parse(card.createdAt); + if (!Number.isFinite(span) || span < 60_000) return null; + const text = formatSubagentDurationMs(span); + return text ? `tracked ${text}` : null; +} + export function AdeCard({ card, onAction, @@ -114,39 +157,6 @@ export function AdeCard({ const known = isKnownAdeCardVariant(card.variant); const deeplink = adeCardDeeplink(card.navTarget); const navigable = Boolean(card.navTarget); - - const elapsed = isLive - ? liveElapsedText(card.createdAt, nowMs) - : formatSubagentDurationMs( - card.createdAt && card.updatedAt - ? Math.max(0, Date.parse(card.updatedAt) - Date.parse(card.createdAt)) - : null, - ); - - const metrics = card.metrics ?? []; - const rows = card.rows ?? []; - const actions = (card.actions ?? []).filter((action) => ( - (action.id === "open" && navigable) || onAction != null - )); - const progress = card.progress ?? null; - const progressTotal = adeCardProgressTotal(progress); - const hasWarning = metrics.some((metric) => normalizeAdeCardTone(metric.tone) === "warning") - || rows.some((row) => normalizeAdeCardTone(row.tone) === "warning") - || (progress?.failed ?? 0) > 0; - - const cardShell = cn( - "w-full max-w-[min(100%,70ch)] overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] border transition-colors", - hasWarning - ? "border-amber-400/16 bg-amber-400/[0.05] hover:border-amber-300/26" - : "border-[color:color-mix(in_srgb,var(--chat-accent)_16%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_6%,transparent)] hover:border-[color:color-mix(in_srgb,var(--chat-accent)_28%,transparent)]", - ); - - const statusParts = [ - isLive ? "running" : "done", - card.subtitle?.trim() || null, - elapsed, - ].filter((part): part is string => Boolean(part)); - const openCard = () => { if (card.navTarget) navigateToAppTarget(card.navTarget); }; @@ -155,7 +165,7 @@ export function AdeCard({ if (!known) { const text = adeCardFallbackText(card); return ( -
+
{text}
@@ -174,132 +184,121 @@ export function AdeCard({ ); } - // Built ONCE and rendered byte-identically whether or not the card navigates — - // the navigable/passive split must never change the layout it wraps. + const metrics = card.metrics ?? []; + const rows = card.rows ?? []; + const progress = card.progress ?? null; + const progressTotal = adeCardProgressTotal(progress); + const degradedReason = card.degradedReason?.trim() || null; + const failedCount = Math.max(0, progress?.failed ?? 0); + const warnRows = rows.filter((row) => normalizeAdeCardTone(row.tone) === "warning" || row.icon === "fail"); + const hasWarning = failedCount > 0 + || warnRows.length > 0 + || metrics.some((metric) => normalizeAdeCardTone(metric.tone) === "warning" && metric.value !== "0"); + + // The two clocks are deliberately different numbers with different labels. + const realDuration = formatSubagentDurationMs(card.durationMs ?? null); + const elapsed = isLive ? liveElapsedText(card.createdAt, nowMs) : null; + const tracked = !isLive && !realDuration ? trackedSpanText(card) : null; + + const headTone: ChatCardTone = isLive ? "running" : hasWarning ? "warn" : degradedReason ? "neutral" : "ok"; + const diff = diffStatFromMetrics(card); + + // A card only earns a box when it has something to put in one. A passing + // result collapses to a single hairline row. + const detailRows = hasWarning ? warnRows : rows; + const showDetail = detailRows.length > 0 && (hasWarning || isLive || card.variant === "proof_artifact"); + const skin = hasWarning ? "rail" : showDetail || isLive || degradedReason ? "inset" : "line"; + + const metaParts = [ + hasWarning && progress ? `${progress.passed} passed` : null, + !hasWarning && !isLive && progressTotal > 0 ? `${progressTotal} job${progressTotal === 1 ? "" : "s"}` : null, + realDuration ? `ran ${realDuration}` : null, + elapsed, + tracked, + ].filter((part): part is string => Boolean(part)); + + const actions = (card.actions ?? []).filter((action) => ( + (action.id === "open" && navigable) || onAction != null + )); + const inner = ( <> - {progress && progressTotal > 0 ? ( -
- {(["passed", "failed", "running", "queued"] as const).map((bucket) => { - const value = Math.max(0, progress[bucket]); - if (value <= 0) return null; - return ( - - ); - })} + : metaParts.join(" · ")} + action={navigable && !actions.length ? ( + // A span, not a button: the whole card already navigates, and nesting + // a second button here would make the card ambiguous to assistive + // tech (and to `getByRole("button")`). + + open + + + ) : null} + > +
+ {card.title} + {card.stale ? stale : null} + {degradedReason ? detail unavailable : null}
- ) : null} + {card.subtitle?.trim() ? {card.subtitle.trim()} : null} + {degradedReason ? {degradedReason} : null} +
-
- - {isLive ? ( - - ) : hasWarning ? ( - - ) : ( - - )} - - -
-
- - {card.title} - - {navigable ? ( - - open - - - ) : null} -
+ {/* A meter only says something while work is still in flight. */} + {isLive && progress && progressTotal > 0 ? ( + + ) : null} - {statusParts.length ? ( -
- {statusParts.join(" · ")} -
- ) : null} + {/* Chips are for counts the head line cannot carry (a diff already did). */} + {!diff && metrics.length && (hasWarning || isLive || card.variant === "proof_artifact") ? ( +
+ {metrics.map((metric) => ( + + {metric.value} {metric.label} + + ))} +
+ ) : null} - {metrics.length ? ( -
- {metrics.map((metric) => { - const tone = normalizeAdeCardTone(metric.tone); - return ( - - {metric.value} - {metric.label} - - ); - })} + {showDetail ? ( + + {detailRows.map((row, index) => ( + + ))} + {card.rowsTruncated && card.rowsTruncated > 0 ? ( +
+ +{card.rowsTruncated} more
) : null} - - {rows.length ? ( -
    - {rows.map((row, index) => { - const tone = normalizeAdeCardTone(row.tone); - return ( -
  • - - {row.text} - {row.detail?.trim() ? ( - - {row.detail} - - ) : null} -
  • - ); - })} -
- ) : null} -
-
+ + ) : null} {actions.length ? ( -
+
{actions.map((action) => ( - + ))}
) : null} @@ -311,7 +310,9 @@ export function AdeCard({ // buttons, and a button inside a button is invalid markup that React and // the browser both mishandle. return ( -
{inner} -
+ ); } - return
{inner}
; + return {inner}; } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 84dda98cc..dbeb2c03a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -11,6 +11,10 @@ import type { ComputerUseArtifactView, } from "../../../shared/types"; import * as modelRegistry from "../../../shared/modelRegistry"; +import { ADE_NAVIGATE_TARGET_EVENT } from "../../lib/openExternal"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; vi.mock("lottie-react", () => ({ useLottie: () => ({ @@ -146,6 +150,7 @@ function renderMessageList( onRetryOlderHistory?: () => void; onReturnToLatest?: () => void; proofArtifacts?: ComputerUseArtifactView[]; + allowLocalProofArtifactProtocol?: boolean; onOpenProofDrawer?: () => void; }, ) { @@ -171,6 +176,7 @@ function renderMessageList( onRetryOlderHistory={options?.onRetryOlderHistory} onReturnToLatest={options?.onReturnToLatest} proofArtifacts={options?.proofArtifacts} + allowLocalProofArtifactProtocol={options?.allowLocalProofArtifactProtocol} onOpenProofDrawer={options?.onOpenProofDrawer} /> @@ -449,12 +455,171 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { - it("renders collected proof even before transcript rows arrive", () => { - renderMessageList([], { proofArtifacts: [transcriptProofArtifact] }); + // Proof used to be appended after every row as a permanently open thread + // footer. With no transcript rows it is now a compact chronological capture + // row that starts collapsed. + it("renders proof attached to an empty chat as a collapsed capture row", () => { + const rendered = renderMessageList([], { proofArtifacts: [transcriptProofArtifact] }); - expect(screen.getByText("Proof collected in this chat")).toBeTruthy(); - expect(screen.getByText("Focused tests passed")).toBeTruthy(); - expect(screen.getByText("381 focused tests passed.")).toBeTruthy(); + expect(screen.queryByText("Proof collected in this chat")).toBeNull(); + expect(rendered.container.querySelector("[data-chat-proof-timeline]")).toBeNull(); + expect(screen.getByRole("button", { name: /Proof added/ }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("chips proof onto the turn rule of the turn that captured it", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Capture proof.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:02:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:01:00.000Z" }] }, + ); + + expect(screen.getByRole("button", { name: /1 proof/ })).toBeTruthy(); + }); + + it("does not attribute proof older than the loaded transcript page to its first visible turn", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Capture current proof.", turnId: "turn-visible" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:02:00.000Z", + event: { type: "done", turnId: "turn-visible", status: "completed" }, + }, + ], + { + hasOlderHistory: true, + proofArtifacts: [ + { ...transcriptProofArtifact, id: "proof-older-page", createdAt: "2026-03-17T09:30:00.000Z" }, + { ...transcriptProofArtifact, id: "proof-visible-turn", createdAt: "2026-03-17T10:01:00.000Z" }, + ], + }, + ); + + expect(screen.getByRole("button", { name: /1 proof/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /2 proof/ })).toBeNull(); + }); + + it("keeps proof captured after the latest done event visible at the transcript tail", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Finish first.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:01:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:02:00.000Z" }] }, + ); + + expect(screen.queryByRole("button", { name: /1 proof/ })).toBeNull(); + expect(screen.getByRole("button", { name: /Proof added/ }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("keeps idle proof before a later turn and never attributes it to that turn", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "First turn.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:01:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:03:00.000Z", + event: { type: "user_message", text: "Later turn.", turnId: "turn-2" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:04:00.000Z", + event: { type: "done", turnId: "turn-2", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:02:00.000Z" }] }, + ); + + const proof = screen.getByRole("button", { name: /Proof added/ }); + const laterTurn = screen.getByText("Later turn."); + expect(proof.compareDocumentPosition(laterTurn) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect(screen.queryByRole("button", { name: /1 proof/ })).toBeNull(); + }); + + it("does not render trailing proof from outside the loaded history window", () => { + renderMessageList([], { + hasOlderHistory: true, + proofArtifacts: [transcriptProofArtifact], + }); + + expect(screen.queryByRole("button", { name: /Proof added/ })).toBeNull(); + }); + + it("renders broken timeline proof as an amber missing state", () => { + renderMessageList( + [{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }], + { + proofArtifacts: [{ + ...transcriptProofArtifact, + availability: "missing_file", + createdAt: "2026-03-17T10:00:00.000Z", + }], + }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 proof/ })); + expect(screen.getByText("Missing proof")).toBeTruthy(); + expect(document.querySelector('[data-chat-proof-broken="true"]')).toBeTruthy(); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("resolves proof thumbnails in the non-virtualized transcript path", () => { + renderMessageList( + [{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }], + { + allowLocalProofArtifactProtocol: true, + proofArtifacts: [{ + ...transcriptProofArtifact, + kind: "screenshot", + mimeType: "image/png", + uri: ".ade/artifacts/proof.png", + createdAt: "2026-03-17T10:00:00.000Z", + }], + }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 proof/ })); + expect(screen.getByRole("img", { name: transcriptProofArtifact.title }).getAttribute("src")) + .toBe("ade-artifact://project/.ade/artifacts/proof.png"); }); it("keeps turn file-change summaries visible without a session id", () => { @@ -1134,7 +1299,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByText("interrupted")).toBeTruthy(); }); - it("labels end-of-turn wall time as ran for, not worked for", () => { + it("labels end-of-turn wall time as ran, not worked for", () => { renderMessageList([ { sessionId: "session-1", @@ -1148,7 +1313,8 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Ran for 2m")).toBeTruthy(); + // The turn rule reads `10:04 · ran 3m 32s` — mono, tabular, lower case. + expect(screen.getByText("ran 2m")).toBeTruthy(); expect(screen.queryByText(/Worked for/)).toBeNull(); }); @@ -2950,12 +3116,55 @@ describe("AgentChatMessageList transcript rendering", () => { expect(rendered.container.textContent).toContain("The focused tests pass."); expect(rendered.container.textContent).toContain("1 file changed"); expect(rendered.container.textContent).not.toContain("npm test"); - expect(rendered.container.textContent).toContain("Ran for 5.0s"); + expect(rendered.container.textContent).toContain("ran 5.0s"); fireEvent.click(screen.getByRole("button", { name: "Show activity from this turn" })); expect(rendered.container.textContent).toContain("npm test"); }); + // "Keep the last": the row you read is the most recent one; quiet successes + // fold behind a count. Failures never fold — burying a rejected command + // behind "+N previous" is exactly the bug this shape must not introduce. + it("keeps the last tool call and folds the quiet successes behind a count", () => { + const command = (index: number, status: "completed" | "failed") => ({ + sessionId: "session-1", + timestamp: `2026-03-17T10:00:0${index}.000Z`, + event: { + type: "command" as const, + command: `step-${index}.sh`, + cwd: "/repo", + output: "ok", + itemId: `command-${index}`, + turnId: "turn-1", + status, + exitCode: status === "completed" ? 0 : 1, + }, + }); + + const rendered = renderMessageList([ + command(1, "completed"), + command(2, "completed"), + command(3, "failed"), + command(4, "completed"), + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:06.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Show activity from this turn" })); + + // Last call + the failure survive; the two quiet successes fold. + expect(rendered.container.textContent).toContain("step-4.sh"); + expect(rendered.container.textContent).toContain("step-3.sh"); + expect(rendered.container.textContent).not.toContain("step-1.sh"); + expect(rendered.container.textContent).toContain("+2 previous tool calls"); + + fireEvent.click(screen.getByText("+2 previous tool calls")); + expect(rendered.container.textContent).toContain("step-1.sh"); + }); + it("keeps mixed provider turn ids together while resetting fallback activity at a new user turn", () => { const rendered = renderMessageList(mixedIdToolActivityBoundaryEvents()); @@ -3132,7 +3341,7 @@ describe("AgentChatMessageList transcript rendering", () => { fireEvent.click(screen.getByRole("button", { name: "Show activity from the active turn" })); expect(rendered.container.textContent).toContain("pwd"); expect(rendered.container.textContent).toContain("shell"); - expect(rendered.container.innerHTML).toContain("max-w-[min(100%,70ch)]"); + expect(rendered.container.innerHTML).toContain("max-w-[var(--chat-content-width,52rem)]"); }); it("renders each subagent as spawn + result cards (no per-tick activity bundle)", () => { @@ -4249,13 +4458,99 @@ describe("AgentChatMessageList ade_card dispatch", () => { expect(screen.queryByText("CI failed")).toBeNull(); }); - it("does not advertise a host action without a real card-action dispatcher", () => { - const onApproval = vi.fn(); - renderMessageList( - [cardEnvelope({ actions: [{ id: "open-lane", label: "Open lane", kind: "primary" }] })], - { onApproval }, - ); - expect(screen.queryByText("Open lane")).toBeNull(); - expect(onApproval).not.toHaveBeenCalled(); + // Previously the transcript passed no `onAction`, and `` filters out + // every action it cannot route — so the schema's action row was unreachable + // by construction. It is now dispatched. + it("renders a host action and broadcasts it as ade:chat:card-action", () => { + const listener = vi.fn(); + window.addEventListener("ade:chat:card-action", listener); + try { + renderMessageList([ + cardEnvelope({ actions: [{ id: "open-lane", label: "Open lane", kind: "primary" }] }), + ]); + fireEvent.click(screen.getByText("Open lane")); + expect(listener).toHaveBeenCalledTimes(1); + const detail = (listener.mock.calls[0]![0] as CustomEvent).detail; + expect(detail).toMatchObject({ actionId: "open-lane", cardId: "run-42", variant: "proof_artifact" }); + } finally { + window.removeEventListener("ade:chat:card-action", listener); + } + }); + + it("routes retry back through the card's own surface rather than a dead broadcast", () => { + const navListener = vi.fn(); + window.addEventListener(ADE_NAVIGATE_TARGET_EVENT, navListener); + try { + renderMessageList([ + cardEnvelope({ + variant: "pr_ci", + title: "CI is running", + degradedReason: "Couldn’t read the job list from GitHub — 403", + navTarget: { kind: "pr", repoOwner: "arul28", repoName: "ADE", prNumber: 916 }, + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + }), + ]); + fireEvent.click(screen.getByText("Retry")); + expect(navListener).toHaveBeenCalled(); + } finally { + window.removeEventListener(ADE_NAVIGATE_TARGET_EVENT, navListener); + } + }); + + it("says the detail is unavailable instead of showing a content-free green card", () => { + renderMessageList([ + cardEnvelope({ + variant: "pr_ci", + title: "CI passed", + metrics: [], + degradedReason: "Couldn’t read the job list from GitHub — 403", + }), + ]); + expect(screen.getByText("detail unavailable")).toBeTruthy(); + expect(screen.getByText(/403/)).toBeTruthy(); + }); +}); + +/** + * The transcript's ONE content width. + * + * Before `--chat-content-width` there were seven disagreeing clamps in this + * directory, and the worst offender resolved `70` characters against the + * browser's 16px default (no card sets a font-size), so every card stopped + * ~26% short of the prose above it. This guard is source-level on purpose: a + * jsdom render cannot catch a clamp on a code path that happens not to be + * exercised. + */ +describe("chat transcript content width", () => { + const chatDir = path.dirname(fileURLToPath(import.meta.url)); + + /** Components only — a test file may name the old clamp to explain it. */ + function chatComponentFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return chatComponentFiles(full); + if (/\.test\.tsx?$/.test(entry.name)) return []; + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + } + + it("has no `ch`-relative card clamp left anywhere under components/chat", () => { + const offenders = chatComponentFiles(chatDir) + .filter((file) => fs.readFileSync(file, "utf8").includes("70ch")) + .map((file) => path.basename(file)); + expect(offenders).toEqual([]); + }); + + it("routes every transcript-row max-width through the shared token", () => { + // `max-w-[min(100%, …)]` is the row-level idiom the redesign unified. A + // bare `max-w-[22rem]` on a nested control is a different thing and stays. + const offenders: string[] = []; + for (const file of chatComponentFiles(chatDir)) { + const source = fs.readFileSync(file, "utf8"); + for (const match of source.matchAll(/max-w-\[min\(100%,\s*[^\]]*\)\]/g)) { + offenders.push(`${path.basename(file)}: ${match[0]}`); + } + } + expect(offenders).toEqual([]); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 8c7209c78..4d7f0e269 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -32,6 +32,8 @@ import { Code, Paperclip, Target, + Clock, + Cube, } from "@phosphor-icons/react"; import type { AgentChatApprovalDecision, @@ -55,7 +57,7 @@ import type { import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { formatTime } from "../../lib/format"; -import { openUrlInAdeBrowser } from "../../lib/openExternal"; +import { navigateToAppTarget, openUrlInAdeBrowser } from "../../lib/openExternal"; import { isPathEqualOrDescendant, isWindowsAbsolutePath, normalizePath } from "../../lib/pathUtils"; import { describeToolIdentifier, replaceInternalToolNames } from "./toolPresentation"; import { chatChipToneClass } from "./chatSurfaceTheme"; @@ -134,7 +136,22 @@ import { ContextCompactDivider } from "./ContextCompactDivider"; import { terminalReasonLabel, formatTimedOutAfter, formatGrepTotalsPrefix } from "./chatEventDisplay"; import { peekPendingSessionAnchor, takePendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { ChatTurnFileChangesPanel, aggregateFiles } from "./ChatFileChangesPanel"; -import { ChatProofTimeline } from "./ChatComputerUsePanel"; +import { + ChatCardRow, + ChatCardSub, + ChatCardTitle, + ChatProofFilmstrip, + formatScheduledRunAt, + type ChatCardTone, +} from "./chatCardPrimitives"; + +/** True for an absolute POSIX or Windows path, which the project handler cannot serve. */ +function path_isAbsoluteLike(value: string): boolean { + return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value); +} + +/** Stable empty array so a proof-free turn never re-renders the divider. */ +const EMPTY_PROOF_ARTIFACTS: ComputerUseArtifactView[] = []; /** * Threaded into MarkdownBlock only for Claude-family sessions. When present, a @@ -235,7 +252,7 @@ function CodexTurnRecoveryCard({ }, [event.turnId, onRecover, pendingAction, resultLabels, targetSessionId]); return ( -
+
recovery @@ -328,7 +345,7 @@ function CodexTurnRecoveryReceipt({ event }: { event: CodexTurnRecoveryEvent }) ? "Recovery failed" : "Recovering"; return ( -
+
{event.state === "recovered" ? : } @@ -1370,13 +1387,22 @@ function activityBundleDetail(item: ChatActivityBundleItem): string | null { const changed = event.items.filter((task) => task.status !== "pending"); return changed.slice(0, 3).map((task) => `${task.status.replace("_", " ")}: ${task.description}`).join(" · ") || null; } + // `nextRunAt` deliberately does NOT fall through to here — a raw ISO string + // (`2026-07-28T12:17:18.016Z`) is not a brief. It renders formatted in the + // row's meta column instead; see `activityBundleWhen`. return event.summary?.trim() || event.error?.trim() || event.cron?.trim() - || event.nextRunAt?.trim() || null; } +/** `runs in 4m · 12:17` for a scheduled row, or null for anything else. */ +function activityBundleWhen(item: ChatActivityBundleItem): string | null { + const event = item.event; + if (event.type !== "scheduled_work_update") return null; + return formatScheduledRunAt(event.nextRunAt); +} + function activityKindLabel(kind: ReturnType): string { if (kind === "task") return "tasks"; return "schedule"; @@ -1387,19 +1413,20 @@ function activityKindTone(kind: ReturnType): string { return "border-amber-300/14 bg-amber-300/[0.055] text-amber-100/72"; } -function activityStatusIcon(item: ChatActivityBundleItem): React.ReactNode { +/** + * Status → the shared card tone. Replaces a bespoke glyph switch that painted + * failures `text-red-300/80` — failures are amber in ADE chat, never red. + */ +function activityBundleTone(item: ChatActivityBundleItem): ChatCardTone { if (item.event.type === "todo_update") { const total = item.event.items.length; const completed = item.event.items.filter((task) => task.status === "completed").length; - return total > 0 && completed === total - ? - : ; + return total > 0 && completed === total ? "ok" : "running"; } const status = activityBundleStatus(item); - if (status.includes("failed")) return ; - if (status.includes("stopped") || status.includes("cancelled")) return ; - if (status.includes("complete")) return ; - return ; + if (status.includes("failed") || status.includes("stopped") || status.includes("cancelled")) return "warn"; + if (status.includes("complete")) return "ok"; + return "running"; } function openChatInfoFromActivity(sessionId: string | null | undefined, taskId: string | null): void { @@ -1468,32 +1495,37 @@ function ActivityBundleRow({ const detail = activityBundleDetail(item); const title = activityBundleTitle(item); const taskId = activityBundleTaskId(item); + // Scheduled work reads as "when, then what" — the clock belongs in the meta + // column with everything else's timing, and the brief sits under the title. + const when = activityBundleWhen(item); return ( ); } @@ -3203,7 +3235,7 @@ function QueueRecoveryCard({ if (settled || expired) return null; return ( -
+
Cleared {messageCount} queued message{messageCount === 1 ? "" : "s"}. @@ -3232,6 +3264,45 @@ function QueueRecoveryCard({ ); } +/** + * Dispatcher for an `ade_card`'s non-`open` actions. + * + * `` filters out every action it cannot route, so before this existed + * the schema's action row was unreachable: no `onAction` prop meant no buttons, + * whatever the emitter sent. Two behaviours: + * + * - `retry` / `refresh` re-enter the card's own surface when it has a nav + * target. That is a real retry, not a no-op: the PR checks tab refetches on + * mount, which is exactly what a rate-limited CI card needs. + * - anything else is broadcast as `ade:chat:card-action` for a host to pick up, + * the same extension shape as `ade:chat:open-info`. + */ +function dispatchAdeCardAction( + card: Extract, + actionId: string, + sessionId: string | null, +): void { + if ((actionId === "retry" || actionId === "refresh") && card.navTarget) { + navigateToAppTarget(card.navTarget); + return; + } + try { + window.dispatchEvent( + new CustomEvent("ade:chat:card-action", { + detail: { + actionId, + cardId: card.cardId, + variant: card.variant, + ...(sessionId ? { sessionId } : {}), + ...(card.navTarget ? { navTarget: card.navTarget } : {}), + }, + }), + ); + } catch { + /* no-op */ + } +} + function renderEvent( envelope: RenderEnvelope, options?: { @@ -3304,7 +3375,7 @@ function renderEvent( ) : content} + {turnProof.length > 0 ? ( + + ) : null}
@@ -4903,7 +5009,9 @@ function DoneTurnDivider({ animate={{ opacity: 1, height: "auto", y: 0 }} exit={{ opacity: 0, height: 0, y: -4 }} transition={{ duration: 0.16, ease: "easeOut" }} - className="mx-auto mt-2 w-full max-w-[min(100%,70ch)] overflow-hidden border-l border-white/[0.08] pl-4" + /* Left-aligned like every other transcript row — this used to be the + only `mx-auto`-centred block in the thread. */ + className="mt-2 w-full max-w-[var(--chat-content-width,52rem)] overflow-hidden border-l border-white/[0.08] pl-4" > ) : null} + {turnProof.length > 0 && proofOpen ? ( +
+ +
+ ) : null}
); } @@ -5097,6 +5215,12 @@ type EventRowProps = { onCancelQueuedMessage?: (uuid: string) => void; onRestoreCancelledQueue?: (recoveryId: string) => Promise; settledQueueRecoveryIds?: Set; + /** Proof captured during this turn — surfaced as a chip on the turn rule. */ + turnProof?: ComputerUseArtifactView[]; + /** Proof captured after this row but outside a completed turn window. */ + inlineProof?: ComputerUseArtifactView[]; + resolveProofThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; + onOpenProofDrawer?: () => void; }; const EventRow = React.memo(function EventRow({ @@ -5143,6 +5267,10 @@ const EventRow = React.memo(function EventRow({ onCancelQueuedMessage, onRestoreCancelledQueue, settledQueueRecoveryIds, + turnProof, + inlineProof, + resolveProofThumbnailSrc, + onOpenProofDrawer, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -5182,7 +5310,7 @@ const EventRow = React.memo(function EventRow({ ) : null} {envelope.event.type === "work_log_group" ? ( -
+
) : null} + {inlineProof?.length ? ( + + ) : null}
); }); @@ -6026,6 +6167,113 @@ function AgentChatMessageListMain({ return map; }, [allGroupedRows]); + /** + * Proof captured during each turn, keyed by the turn's `done` row. + * + * Proof itself renders inline where it was captured (an `ade_card` row), so + * this is only the turn summary's "N proof" chip — a way back to the drawer + * from the turn that produced the capture, not a second copy of the artifacts. + * Bucketing is by wall clock because artifacts carry `createdAt`, not turnId. + */ + /** + * Thumbnail source for inline proof. The stored `uri` is project-relative + * (`.ade/artifacts/...`), and the `ade-artifact://project/` handler resolves + * exactly that against the active project root — so a local project gets real + * previews synchronously, with no per-tile IPC. A remote project has no such + * handler, so tiles fall back to their kind label and the drawer (which reads + * bytes over the runtime) stays the way to view them. + */ + const resolveProofThumbnailSrc = useCallback((artifact: ComputerUseArtifactView): string | null => { + if (!allowLocalProofArtifactProtocol) return null; + const uri = artifact.uri?.trim(); + if (!uri) return null; + if (/^ade-artifact:\/\//i.test(uri)) return uri; + if (/^https?:\/\//i.test(uri) || path_isAbsoluteLike(uri)) return null; + return `ade-artifact://project/${uri.split("/").map(encodeURIComponent).join("/")}`; + }, [allowLocalProofArtifactProtocol]); + + const turnProofTimeline = useMemo(() => { + const byDoneRowKey = new Map(); + const inlineByRowKey = new Map(); + if (!proofArtifacts.length) { + return { byDoneRowKey, inlineByRowKey, unanchored: EMPTY_PROOF_ARTIFACTS }; + } + const stamped = proofArtifacts + .map((artifact) => ({ artifact, at: Date.parse(artifact.createdAt) })) + .filter((entry) => Number.isFinite(entry.at)) + .sort((left, right) => left.at - right.at); + if (!stamped.length) { + return { byDoneRowKey, inlineByRowKey, unanchored: EMPTY_PROOF_ARTIFACTS }; + } + const loadedTranscriptStart = allGroupedRows.reduce((earliest, env) => { + const at = Date.parse(env.timestamp); + return Number.isFinite(at) ? Math.min(earliest, at) : earliest; + }, Number.POSITIVE_INFINITY); + if (!Number.isFinite(loadedTranscriptStart)) { + return { + byDoneRowKey, + inlineByRowKey, + // With no loaded rows, an older page means this is not the transcript + // boundary. Do not pull unknown historic proof into the visible tail. + unanchored: hasOlderHistory ? EMPTY_PROOF_ARTIFACTS : stamped.map((entry) => entry.artifact), + }; + } + const visibleStamped = stamped.filter((entry) => entry.at >= loadedTranscriptStart); + const assignedIds = new Set(); + let turnStartMs: number | null = null; + for (const env of allGroupedRows) { + const rowMs = Date.parse(env.timestamp); + if (!Number.isFinite(rowMs)) continue; + if ( + turnStartMs == null + && (getGroupedTurnId(env) != null || env.event.type === "user_message") + ) { + turnStartMs = rowMs; + } + if (env.event.type !== "done") continue; + const endMs = rowMs; + const startMs = turnStartMs ?? endMs; + const captured = visibleStamped + .filter((entry) => entry.at >= startMs && entry.at <= endMs) + .map((entry) => entry.artifact); + if (captured.length > 0) { + byDoneRowKey.set(env.key, captured); + for (const artifact of captured) assignedIds.add(artifact.id); + } + turnStartMs = null; + } + + const visibleRows = groupedRows + .map((row) => ({ row, at: Date.parse(row.timestamp) })) + .filter((entry) => Number.isFinite(entry.at)) + .sort((left, right) => left.at - right.at); + const unanchored: ComputerUseArtifactView[] = []; + for (const entry of visibleStamped) { + if (assignedIds.has(entry.artifact.id)) continue; + let anchorKey: string | null = null; + for (const row of visibleRows) { + if (row.at > entry.at) break; + anchorKey = row.row.key; + } + if (!anchorKey) { + unanchored.push(entry.artifact); + continue; + } + const existing = inlineByRowKey.get(anchorKey) ?? []; + existing.push(entry.artifact); + inlineByRowKey.set(anchorKey, existing); + } + + return { + byDoneRowKey, + inlineByRowKey, + unanchored, + }; + }, [allGroupedRows, groupedRows, hasOlderHistory, proofArtifacts]); + const turnProofByRowKey = turnProofTimeline.byDoneRowKey; + const inlineProofByRowKey = turnProofTimeline.inlineByRowKey; + const unanchoredProofArtifacts = turnProofTimeline.unanchored; + const handleReviewChanges = useCallback(() => { if (!turnSummary?.changedFileCount) return; const state = currentLaneId ? { laneId: currentLaneId } : undefined; @@ -6756,6 +7004,10 @@ function AgentChatMessageListMain({ const turnToolEntries = envelope.event.type === "done" ? (transcriptToolActivity.byDoneRowKey.get(envelope.key) ?? []) : undefined; + const turnProof = envelope.event.type === "done" + ? turnProofByRowKey.get(envelope.key) + : undefined; + const inlineProof = inlineProofByRowKey.get(envelope.key); const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; @@ -6779,6 +7031,10 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} + turnProof={turnProof} + inlineProof={inlineProof} + resolveProofThumbnailSrc={resolveProofThumbnailSrc} + onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} onRecoverContinuity={onRecoverContinuity} @@ -6829,6 +7085,10 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} + turnProof={turnProof} + inlineProof={inlineProof} + resolveProofThumbnailSrc={resolveProofThumbnailSrc} + onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} onRecoverContinuity={onRecoverContinuity} @@ -6867,7 +7127,7 @@ function AgentChatMessageListMain({ settledQueueRecoveryIds={settledQueueRecoveryIds} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, inlineProofByRowKey, resolveProofThumbnailSrc, onOpenProofDrawer]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -6885,7 +7145,7 @@ function AgentChatMessageListMain({ const streamingIndicator = showStreamingIndicator && !sessionEnded ? ( 0 ? ( +
+ +
+ ) : null; // Jump-to-latest pill is only meaningful during an active turn — if nothing // is streaming there's no "latest" to catch up to. @@ -6979,7 +7251,9 @@ function AgentChatMessageListMain({ ) : null}
) : null} - {rows.length === 0 && !streamingIndicator && proofArtifacts.length === 0 ? ( + {/* Proof with no following turn completion is a chronological tail + row, not the old permanently pinned footer. */} + {rows.length === 0 && !streamingIndicator && !trailingProof ? ( null ) : shouldVirtualize ? ( /* ── Virtualized path: only render rows in / near the viewport ── */ @@ -6995,25 +7269,17 @@ function AgentChatMessageListMain({ {/* Bottom spacer fills remaining scroll area */}
+ {trailingProof} {streamingIndicator} {turnDivider} -
) : ( /* ── Non-virtualized path: render all rows (small conversation) ── */
{groupedRows.map((envelope, index) => renderRow(envelope, index, false))} + {trailingProof} {streamingIndicator} {turnDivider} -
)}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 23b5caebb..e2ee847e6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -14,6 +14,9 @@ import type { AgentChatSessionSummary, AgentChatSteerResult, AiSettingsStatus, + ComputerUseArtifactView, + ComputerUseEventPayload, + ComputerUseOwnerSnapshot, PrSummary, TerminalSessionChangedEvent, TerminalSessionDetail, @@ -585,6 +588,7 @@ function installAdeMocks(options?: { const writeClipboardText = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); + const computerUseEventListeners = new Set<(event: ComputerUseEventPayload) => void>(); globalThis.window.ade = { app: { @@ -714,7 +718,13 @@ function installAdeMocks(options?: { }, computerUse: { getOwnerSnapshot: vi.fn().mockResolvedValue({ artifacts: [] }), - onEvent: vi.fn().mockImplementation(() => () => undefined), + readArtifactPreview: vi.fn().mockResolvedValue(null), + onEvent: vi.fn().mockImplementation((listener: (event: ComputerUseEventPayload) => void) => { + computerUseEventListeners.add(listener); + return () => { + computerUseEventListeners.delete(listener); + }; + }), }, files: { listWorkspaces: vi.fn().mockResolvedValue([]), @@ -814,6 +824,11 @@ function installAdeMocks(options?: { listener(event); } }, + emitComputerUseEvent: (event: ComputerUseEventPayload) => { + for (const listener of computerUseEventListeners) { + listener(event); + } + }, }; } @@ -1398,6 +1413,76 @@ describe("AgentChatPane companion drawers", () => { expect(screen.queryByRole("button", { name: "Close chat actions drawer" })).toBeNull(); }); + it("removes deleted proof from the open drawer when the event no longer has an owner", async () => { + const session = buildSession("session-1", { title: "Proof event chat" }); + const proof: ComputerUseArtifactView = { + id: "proof-deleted", + kind: "screenshot", + backendStyle: "local_fallback", + backendName: "ADE", + sourceToolName: "capture", + originalType: "image", + title: "Proof to delete", + description: null, + uri: ".ade/artifacts/proof-deleted.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + createdAt: "2026-07-28T12:00:00.000Z", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + }; + const mocks = installAdeMocks({ sessions: [session] }); + const populatedSnapshot: ComputerUseOwnerSnapshot = { + owner: { kind: "chat_session", id: session.sessionId }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: "1 proof item", + activeBackend: null, + artifacts: [proof], + recentArtifacts: [proof], + activity: [], + }; + vi.mocked(window.ade.computerUse.getOwnerSnapshot) + .mockResolvedValueOnce(populatedSnapshot) + .mockResolvedValue({ + owner: { kind: "chat_session", id: session.sessionId }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: "No proof", + activeBackend: null, + artifacts: [], + recentArtifacts: [], + activity: [], + }); + seedDrawerStore(); + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Proof" })); + expect(await screen.findByText("Proof to delete")).toBeTruthy(); + + act(() => { + mocks.emitComputerUseEvent({ + type: "artifact-deleted", + artifactId: proof.id, + at: "2026-07-28T12:01:00.000Z", + owner: null, + }); + }); + + await waitFor(() => expect(screen.queryByText("Proof to delete")).toBeNull()); + expect(await screen.findByText("No proof collected yet")).toBeTruthy(); + expect(window.ade.computerUse.getOwnerSnapshot).toHaveBeenCalledTimes(2); + }); + it("does not reopen chat actions after tasks arrive while the Agents tab is already open", async () => { const session = buildSession("session-1"); const { emitChatEvent } = installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 016ecc9f3..ff2a6a686 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -189,7 +189,7 @@ import { ClaudeLoginPromptButton, createClaudeLoginTerminalInWork } from "../wor import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { setLaneNaming } from "../../state/laneNamingStore"; -import { buildChatAppearanceRootStyle } from "./chatAppearance"; +import { buildChatAppearanceRootStyle, resolveChatContentWidthPx } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { buildLaneBindingIndex, @@ -926,9 +926,9 @@ function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { const PANE_RESERVE_RIGHT_PX = 276; // 16.5rem pane + 12px gutter const PANE_RESERVE_LEFT_PX = 276; // 16.5rem pane + 12px gutter const CHAT_MIN_WIDTH_PX = 360; // recenter the chat as soon as a normal screen allows -// The centered chat column's default width (`--chat-column`, 52rem @ 16px root). -// Used to tell whether a floating pane already fits in the chat's side margin. -const CHAT_COLUMN_PX = 832; +// The centered chat column's width. NOT a constant any more: it is the JS half +// of the `--chat-content-width` token (chatAppearance.ts), so this maths and the +// CSS that lays the column out can never disagree. /** * Reserve gutter space for the floating panes — but ONLY when they'd otherwise * overlap the centered chat column. When the window is wide enough that a pane @@ -944,7 +944,7 @@ function computePaneReserve( ): { left: string; right: string } { if (width <= 0) return { left: "0px", right: "0px" }; // Free space on each side of the centered column at full width. - const naturalSideMargin = Math.max(0, (width - CHAT_COLUMN_PX) / 2); + const naturalSideMargin = Math.max(0, (width - resolveChatContentWidthPx(width)) / 2); let right = 0; if ( rightOpen @@ -7393,7 +7393,21 @@ export function AgentChatPane({ if (!isTileActive) return undefined; const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; - if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { + const belongsToSelectedChat = event.owner?.kind === "chat_session" + && event.owner.id === selectedSessionId; + // Global/CLI deletes can arrive after their owner links are gone, so the + // event may not carry an owner. Optimistically remove the matching id + // from this drawer, then force an authoritative snapshot refresh. + if (event.type === "artifact-deleted" && (event.owner == null || belongsToSelectedChat)) { + setComputerUseSnapshot((current) => current + ? { + ...current, + artifacts: current.artifacts.filter((artifact) => artifact.id !== event.artifactId), + recentArtifacts: current.recentArtifacts.filter((artifact) => artifact.id !== event.artifactId), + } + : current); + void refreshComputerUseSnapshot(selectedSessionId, { force: true }); + } else if (belongsToSelectedChat) { void refreshComputerUseSnapshot(selectedSessionId, { force: true }); } }); @@ -12258,7 +12272,7 @@ export function AgentChatPane({ const SIDE_PANE_WIDTH_PX = 264; // 16.5rem const centeredPaneOffsetPx = Math.max( 12, - Math.round(((chatAreaWidth - CHAT_COLUMN_PX) / 2 - SIDE_PANE_WIDTH_PX) / 2), + Math.round(((chatAreaWidth - resolveChatContentWidthPx(chatAreaWidth)) / 2 - SIDE_PANE_WIDTH_PX) / 2), ); const rightPaneOffsetPx = paneReserve.right === "0px" ? centeredPaneOffsetPx : 12; const leftPaneOffsetPx = paneReserve.left === "0px" ? centeredPaneOffsetPx : 12; diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx index 882a58f7d..27ca030e4 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx @@ -31,6 +31,21 @@ function artifact(index: number, overrides: Partial = { }; } +function snapshotOf(artifacts: ComputerUseArtifactView[]): ComputerUseOwnerSnapshot { + return { + owner: { kind: "chat_session", id: "session-1" }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: `${artifacts.length} proof item`, + activeBackend: null, + artifacts, + recentArtifacts: artifacts, + activity: [], + }; +} + beforeEach(() => { delete (window as unknown as { IntersectionObserver?: unknown }).IntersectionObserver; (window as unknown as { ade: unknown }).ade = { @@ -112,6 +127,27 @@ describe("proof rendering", () => { }); }); + it("distinguishes an unavailable preview from a deleted stored file", async () => { + vi.mocked(window.ade.computerUse.readArtifactPreview).mockResolvedValueOnce(null); + + render(); + + expect(await screen.findByText(/preview is unavailable, but the stored proof is still attached/i)).toBeTruthy(); + expect(screen.queryByText(/stored file has since been deleted/i)).toBeNull(); + }); + + it("reports a missing stored file as deleted", async () => { + render(); + + expect(await screen.findByText(/stored file has since been deleted/i)).toBeTruthy(); + }); + it("keeps the latest six proof items inline and links earlier proof to the drawer", async () => { const onOpenDrawer = vi.fn(); render( @@ -128,6 +164,115 @@ describe("proof rendering", () => { expect(onOpenDrawer).toHaveBeenCalledTimes(1); }); + it("deletes a drawer item and refreshes, instead of leaving the user no way to remove it", async () => { + const onRefresh = vi.fn().mockResolvedValue(undefined); + const deleteArtifacts = vi.fn().mockResolvedValue({ deleted: [], missing: [], failed: [], freedBytes: 0 }); + (window.ade as any).computerUse.deleteArtifacts = deleteArtifacts; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + await waitFor(() => expect(deleteArtifacts).toHaveBeenCalledWith({ artifactId: "artifact-1" })); + await waitFor(() => expect(onRefresh).toHaveBeenCalled()); + }); + + it("explains why a never-imported capture is blank and offers to locate or remove it", async () => { + const readPreview = vi.mocked(window.ade.computerUse.readArtifactPreview); + const recoverArtifact = vi.fn().mockResolvedValue({}); + (window.ade as any).computerUse.recoverArtifact = recoverArtifact; + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({}); + + const broken = artifact(4, { + title: "Lost proof", + availability: "unimported", + uri: "shots/proof.png", + metadata: { + sourcePath: "shots/proof.png", + callerRoot: "/project/.ade/worktrees/lane-a", + }, + }); + render(); + + // Honest, specific copy — not "Preview unavailable from the connected runtime." + expect(await screen.findByText(/Never copied into ADE's storage/)).toBeTruthy(); + expect(screen.getByText(/1 item has no stored file/)).toBeTruthy(); + // No point asking the host for bytes it already told us do not exist. + expect(readPreview).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Locate Lost proof in its lane" })); + await waitFor(() => expect(recoverArtifact).toHaveBeenCalledWith({ artifactId: "artifact-4" })); + }); + + it("offers recovery for a local source URI but not an HTTP source URI", async () => { + const recoverArtifact = vi.fn().mockResolvedValue({}); + (window.ade as any).computerUse.recoverArtifact = recoverArtifact; + + const localUri = artifact(8, { + title: "URI proof", + availability: "unimported", + metadata: { + sourceUri: "shots/from-uri.png", + callerRoot: "/project/.ade/worktrees/lane-b", + }, + }); + const remoteUri = artifact(9, { + title: "Remote proof", + availability: "unimported", + metadata: { sourceUri: "https://example.com/proof.png" }, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Locate URI proof in its lane" })); + await waitFor(() => expect(recoverArtifact).toHaveBeenCalledWith({ artifactId: "artifact-8" })); + expect(screen.queryByRole("button", { name: "Locate Remote proof in its lane" })).toBeNull(); + }); + + it("surfaces a failed delete instead of silently doing nothing", async () => { + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockRejectedValue(new Error("Artifact is locked")); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + expect(await screen.findByText("Artifact is locked")).toBeTruthy(); + }); + + it("surfaces resolved delete failures and does not refresh unchanged proof", async () => { + const onRefresh = vi.fn(); + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({ + deleted: [], + missing: [], + failed: [{ artifactId: "artifact-1", reason: "Permission denied" }], + freedBytes: 0, + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + expect(await screen.findByText("Permission denied")).toBeTruthy(); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it("surfaces resolved bulk-prune failures", async () => { + const broken = artifact(7, { availability: "missing_file" }); + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({ + deleted: [], + missing: [], + failed: [{ artifactId: broken.id, reason: "File is locked" }], + freedBytes: 0, + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Remove it" })); + + expect(await screen.findByText("File is locked")).toBeTruthy(); + }); + it("uses a remote-safe external action only for HTTP artifacts", async () => { const remote = artifact(3, { uri: "https://proof.example/capture.png", diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index b7071e818..bad6bd40f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -3,7 +3,9 @@ import { Cube, FileText, ImageSquare, + MagnifyingGlass, SpinnerGap, + Trash, VideoCamera, WarningCircle, X, @@ -16,7 +18,11 @@ import React, { useState, } from "react"; import { createPortal } from "react-dom"; -import type { ComputerUseArtifactView, ComputerUseOwnerSnapshot } from "../../../shared/types"; +import type { + ComputerUseArtifactDeleteResult, + ComputerUseArtifactView, + ComputerUseOwnerSnapshot, +} from "../../../shared/types"; import { cn } from "../ui/cn"; function isImageArtifact(artifact: ComputerUseArtifactView): boolean { @@ -48,6 +54,60 @@ function externalArtifactUrl(uri: string): string | null { return /^https?:\/\//i.test(uri) ? uri : null; } +/** + * Hosts that predate availability reporting omit the field. Treat that as + * "available" and let the preview read decide, rather than showing a broken + * state we have no evidence for. + */ +function artifactAvailability(artifact: ComputerUseArtifactView): "available" | "missing_file" | "unimported" { + return artifact.availability ?? "available"; +} + +function isBrokenArtifact(artifact: ComputerUseArtifactView): boolean { + return artifactAvailability(artifact) !== "available"; +} + +function localSourceValue(value: unknown): string | null { + const source = typeof value === "string" ? value.trim() : ""; + return source && !externalArtifactUrl(source) ? source : null; +} + +function recoverableArtifactSource(artifact: ComputerUseArtifactView): string | null { + return localSourceValue(artifact.metadata?.sourcePath) + ?? localSourceValue(artifact.metadata?.sourceUri); +} + +function shortSourcePath(artifact: ComputerUseArtifactView): string | null { + const value = recoverableArtifactSource(artifact) + ?? localSourceValue(artifact.metadata?.absolutePath); + if (!value) return null; + const segments = value.split(/[\\/]/).filter(Boolean); + return segments.length > 2 ? `…/${segments.slice(-2).join("/")}` : value; +} + +/** + * Keep canonical storage availability separate from preview generation. + * A missing preview can mean unsupported media or a size cap even when the + * stored proof is intact. + */ +function artifactPreviewExplanation(artifact: ComputerUseArtifactView): string { + const where = shortSourcePath(artifact); + if (artifactAvailability(artifact) === "unimported") { + return where + ? `Never copied into ADE's storage — it was left at ${where} in the lane it was captured in.` + : "Never copied into ADE's storage, so there are no bytes to show."; + } + if (artifactAvailability(artifact) === "missing_file") { + return "The stored file has since been deleted."; + } + return "A preview is unavailable, but the stored proof is still attached."; +} + +function assertArtifactDeletionSucceeded(result: ComputerUseArtifactDeleteResult): void { + if (result.failed.length === 0) return; + throw new Error(result.failed.map((failure) => failure.reason).join("; ")); +} + function localArtifactUrl(uri: string): string | null { return /^ade-artifact:\/\/project(?:\/|$)/i.test(uri) ? uri : null; } @@ -103,6 +163,9 @@ function useVisibleArtifactPreview( !visible || loaded || !artifact.uri + // The host already told us there are no bytes; asking for them anyway + // just burns an IPC round trip per tile to get null back. + || isBrokenArtifact(artifact) || (!isImageArtifact(artifact) && !isVideoArtifact(artifact)) ) return; const directPreview = allowLocalArtifactProtocol @@ -119,15 +182,14 @@ function useVisibleArtifactPreview( .then((dataUrl) => { if (cancelled) return; setPreview(dataUrl); + setLoading(false); setLoaded(true); }) .catch(() => { if (cancelled) return; setPreview(null); + setLoading(false); setLoaded(true); - }) - .finally(() => { - if (!cancelled) setLoading(false); }); return () => { cancelled = true; @@ -267,11 +329,15 @@ export function ChatProofArtifactCard({
) : mediaFailed || (loaded && !preview && (image || video)) ? ( -
- - - Preview unavailable{externalUrl ? " here. Open the source to view it." : " from the connected runtime."} - + // Broken proof shrinks. A full-height empty box wastes the most + // valuable space in a 322px rail to say nothing. +
+ +
+ {externalUrl + ? "This proof lives at its source. Open it to view." + : artifactPreviewExplanation(artifact)} +
) : preview && image ? ( + ) : preview && video ? ( +
+ +
+
+ {artifact.title} +
+
+ {relativeTime(artifact.createdAt)} +
+ {hasPreviewProblem ? ( +
+ {externalUrl ? "Stored at its source." : artifactPreviewExplanation(artifact)} +
+ ) : null} +
+ + {lightboxOpen && preview && image ? ( + setLightboxOpen(false)} + /> + ) : null} +
+ ); +} + export function ChatComputerUsePanel({ snapshot, onRefresh, @@ -391,7 +607,64 @@ export function ChatComputerUsePanel({ onRefresh: () => void | Promise; allowLocalArtifactProtocol?: boolean; }) { - const artifacts = snapshot?.artifacts ?? []; + // Stable identity: `?? []` would hand every memo below a fresh array each + // render and defeat them. + const artifacts = useMemo(() => snapshot?.artifacts ?? [], [snapshot]); + const [busyIds, setBusyIds] = useState>(() => new Set()); + const [error, setError] = useState(null); + + const brokenCount = useMemo( + () => artifacts.filter((artifact) => isBrokenArtifact(artifact)).length, + [artifacts], + ); + + const withBusy = useCallback( + async (ids: string[], run: () => Promise) => { + setError(null); + setBusyIds((current) => new Set([...current, ...ids])); + try { + await run(); + await onRefresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyIds((current) => { + const next = new Set(current); + for (const id of ids) next.delete(id); + return next; + }); + } + }, + [onRefresh], + ); + + const handleDelete = useCallback( + (artifact: ComputerUseArtifactView) => { + void withBusy([artifact.id], async () => { + const result = await window.ade.computerUse.deleteArtifacts({ artifactId: artifact.id }); + assertArtifactDeletionSucceeded(result); + }); + }, + [withBusy], + ); + + const handleRecover = useCallback( + (artifact: ComputerUseArtifactView) => { + void withBusy([artifact.id], () => + window.ade.computerUse.recoverArtifact({ artifactId: artifact.id }), + ); + }, + [withBusy], + ); + + const handlePruneBroken = useCallback(() => { + const ids = artifacts.filter((artifact) => isBrokenArtifact(artifact)).map((artifact) => artifact.id); + if (!ids.length) return; + void withBusy(ids, async () => { + const result = await window.ade.computerUse.deleteArtifacts({ artifactIds: ids }); + assertArtifactDeletionSucceeded(result); + }); + }, [artifacts, withBusy]); if (!snapshot || artifacts.length === 0) { return ( @@ -408,10 +681,10 @@ export function ChatComputerUsePanel({ } return ( -
-
+
+
- {artifacts.length} item{artifacts.length === 1 ? "" : "s"} collected + {artifacts.length} item{artifacts.length === 1 ? "" : "s"}
-
+ + {brokenCount > 0 ? ( +
+ + {brokenCount} item{brokenCount === 1 ? " has" : "s have"} no stored file. + + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
{artifacts.map((artifact) => ( - ))}
diff --git a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx index a88effa69..1ce7ea057 100644 --- a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { CaretDown, + CaretRight, FileCode, FilePlus, FileX, @@ -15,6 +16,12 @@ import type { import { AdeDiffViewer } from "../shared/AdeDiffViewer"; import { cn } from "../ui/cn"; import { BottomDrawerSection } from "./BottomDrawerSection"; +import { + CHAT_CARD_WIDTH_CLASS, + ChatCardDiffStat, + ChatCardRow, + ChatCardTitle, +} from "./chatCardPrimitives"; /* ── Helpers ── */ @@ -348,6 +355,14 @@ function NestedFileChangesSection({ ); } +/** + * Turn-level "files changed" — ONE line by default. + * + * `7 files changed` with the diff stat in the meta column and a `diff ›` + * affordance; the browser only unfolds when asked. It uses the shared + * `[glyph | content | meta]` grid so its title and numbers sit in the same + * columns as the CI, PR and agent cards above and below it. + */ export const ChatTurnFileChangesPanel = React.memo(function ChatTurnFileChangesPanel({ turnSummary, threadSummaries, @@ -361,15 +376,27 @@ export const ChatTurnFileChangesPanel = React.memo(function ChatTurnFileChangesP const turnFiles = useMemo(() => aggregateFiles([turnSummary]), [turnSummary]); if (!turnFiles.length) return null; + const additions = turnFiles.reduce((sum, file) => sum + file.additions, 0); + const deletions = turnFiles.reduce((sum, file) => sum + file.deletions, 0); + return ( -
- - - Files changed - - - View diffs - +
+ + } + action={( + + diff + + + )} + > + + {turnFiles.length} file{turnFiles.length === 1 ? "" : "s"} changed + +
diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index acc32c138..103ce72c7 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -656,7 +656,24 @@ export function ChatToolActivityDetails({ sessionId?: string | null; }) { const activityEntries = useMemo(() => dedupeChatToolActivityEntries(entries), [entries]); + // Keep the LAST call — the one that says what the agent is doing now — plus + // anything that FAILED, and fold the quiet successes behind a count. A 50-row + // scroll box of finished tool calls is noise; a rejected hook or a failed + // command is not, and must never be what the fold hides. + const [showHistory, setShowHistory] = useState(false); + const visibleEntries = useMemo(() => { + if (showHistory || activityEntries.length <= 1) return activityEntries; + const lastIndex = activityEntries.length - 1; + return activityEntries.filter((entry, index) => ( + index === lastIndex + || entry.tone === "error" + || entry.status === "failed" + || entry.status === "interrupted" + || entry.status === "running" + )); + }, [activityEntries, showHistory]); if (activityEntries.length === 0) return null; + const hiddenCount = activityEntries.length - visibleEntries.length; return (
+ {hiddenCount > 0 ? ( + + ) : null}
- {activityEntries.map((entry) => ( + {visibleEntries.map((entry) => ( ))}
diff --git a/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx b/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx index 6857f4810..ca752fd78 100644 --- a/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx +++ b/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx @@ -42,7 +42,8 @@ export function ContextCompactDivider({ event }: ContextCompactDividerProps) { ) : null} - {event.agentType?.trim() && event.agentType.trim() !== "background" ? ( - - {event.agentType.trim()} + {/* Role, not id. Codex hands us its internal agent path + (`/ROOT/SHIP_POLL_927`); `uppercase` on top of that made it shout a + file path at the reader. `humanizeAgentIdentity` turns the last + segment into a role and lifts a trailing issue/PR number into its + own chip; runtimes that never set an agent type (OpenCode, Droid) + get null and render no chip at all. The raw value stays as the + tooltip so nothing is lost. */} + {agentIdentity ? ( + + {agentIdentity.label} + + ) : null} + {agentIdentity?.ref ? ( + + {agentIdentity.ref} ) : null} {event.background ? ( @@ -207,11 +234,18 @@ export function SubagentSpawnCard({ } /** - * Result card at the chronological position where the agent ended. Status + - * duration, a ~2-line preview of the final report, "View transcript", and a - * "↑ jump to start" affordance. Warm terminal states: stopped → amber tone, - * failed → reason chip + a `Details` disclosure with the error text (never a red - * error block). + * Result card at the chronological position where the agent ended — the "role + + * result" shape: what it was, what it found, how long it took. + * + * The head line is the agent's TASK, not the word "Finished": a transcript full + * of `Finished / Finished / Finished` says nothing, and the status is already + * carried by the glyph and the tone. The body is the real report preview; + * runtime filler (`"Agent completed"`) is filtered out by + * {@link firstMeaningfulSummary} and falls back to the status word rather than + * printing placeholder text where a result belongs. + * + * Warm terminal states throughout: stopped → amber tone, failed → an `error` + * chip plus a `Details` disclosure. Never a red error block. */ export function SubagentResultCard({ event, @@ -227,96 +261,90 @@ export function SubagentResultCard({ const isStopped = event.status === "stopped"; const isFailed = event.status === "failed"; const duration = formatSubagentDurationMs(event.durationMs); + const statusWord = isSuccess ? "Finished" : isStopped ? "Stopped — interrupted" : "Failed"; + const title = event.description?.trim() || statusWord; + const summary = firstMeaningfulSummary(event.summaryPreview); - const toneCard = isSuccess - ? "border-[color:color-mix(in_srgb,var(--chat-accent)_14%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_5%,transparent)]" - : isStopped - ? "border-amber-300/14 bg-amber-300/[0.045]" - : "border-amber-400/16 bg-amber-400/[0.05]"; - const statusLabel = isSuccess ? "Finished" : isStopped ? "stopped — interrupted" : "Failed"; - const statusColor = isSuccess ? "text-fg/70" : "text-amber-100/85"; + const counters = [ + typeof event.toolUseCount === "number" && event.toolUseCount > 0 + ? `${event.toolUseCount} tool${event.toolUseCount === 1 ? "" : "s"}` + : null, + typeof event.totalTokens === "number" && event.totalTokens > 0 + ? `${formatContextTokens(event.totalTokens)} tokens` + : null, + ].filter((part): part is string => Boolean(part)); return ( -
-
- - {isSuccess ? ( - - ) : isStopped ? ( - - ) : ( - - )} - -
-
- - {statusLabel} - - {duration ? ( - {duration} - ) : null} - {typeof event.toolUseCount === "number" && event.toolUseCount > 0 ? ( - - · {event.toolUseCount} tool{event.toolUseCount === 1 ? "" : "s"} - - ) : null} - {typeof event.totalTokens === "number" && event.totalTokens > 0 ? ( - - · {formatContextTokens(event.totalTokens)} tokens - - ) : null} - {isFailed && event.error?.trim() ? ( - - error - - ) : null} - {event.worktreeBranch?.trim() ? ( + + + {onViewTranscript ? ( ) : null} - {event.parentLabel ? ( - spawned by {event.parentLabel} + {onJumpToStart ? ( + ) : null} -
- {event.summaryPreview?.trim() ? ( -
- {event.summaryPreview.trim()} -
+ + ) : null} + > +
+ {title} + {!isSuccess && event.description?.trim() ? ( + + {statusWord.toLowerCase()} + ) : null} -
-
- {onViewTranscript ? ( - + {isFailed && event.error?.trim() ? ( + + error + ) : null} - {onJumpToStart ? ( + {event.worktreeBranch?.trim() ? ( ) : null} + {event.parentLabel ? ( + spawned by {event.parentLabel} + ) : null}
-
+ {summary ? ( +
+ {summary} +
+ ) : null} + {counters.length ? ( +
+ {counters.join(" · ")} +
+ ) : null} + {isFailed && event.error?.trim() ? ( -
+
) : null} -
+ ); } @@ -354,7 +382,7 @@ export function BackgroundFinishChip({ event }: { event: BackgroundFinishChipRen return (
void; }) { - const [expanded, setExpanded] = useState(false); const count = event.count; + const [expanded, setExpanded] = useState(count <= 6); const headline = `${count} ${count === 1 ? "agent" : "agents"} stopped when you interrupted`; return ( -
- + + {expanded ? ( -
    + {event.items.map((item) => ( -
  • - - {item.title} - - {onJumpToStart ? ( - - ) : null} -
  • + onJumpToStart(item.jumpToStartRowKey) : undefined} + /> ))} -
+ ) : null} -
+ ); } diff --git a/apps/desktop/src/renderer/components/chat/chatAppearance.ts b/apps/desktop/src/renderer/components/chat/chatAppearance.ts index b3e3987da..3b18e5a61 100644 --- a/apps/desktop/src/renderer/components/chat/chatAppearance.ts +++ b/apps/desktop/src/renderer/components/chat/chatAppearance.ts @@ -50,6 +50,48 @@ export function transcriptBubblePaddingPx(density: ChatTranscriptDensity): { } } +/** + * The transcript's ONE content width. + * + * Every row — prose, cards, plan, files-changed, activity bundles, pills — + * clamps to `--chat-content-width` and nothing else. Before this token there + * were seven disagreeing clamps (832 / 804 / 736 / 704 / 672 / 616 / 544 px), + * and the card clamp — 70 characters, which resolved against the browser's 16px + * default because no card sets a font-size — stopped ~26% short of the prose it + * sat under. + * + * It scales with the viewport on purpose: a fixed 832px wastes most of a 27" + * display. `min(100%, clamp(min, vw, max))` keeps the reading measure sane on a + * laptop, grows on a large screen, and never exceeds its container — so an open + * side pane (which shrinks the container, not the viewport) narrows the column + * rather than clipping it. + */ +export const CHAT_CONTENT_WIDTH_MIN_PX = 720; +export const CHAT_CONTENT_WIDTH_MAX_PX = 1180; +export const CHAT_CONTENT_WIDTH_VIEWPORT_RATIO = 0.62; + +/** CSS half of the token. Must stay in lockstep with {@link resolveChatContentWidthPx}. */ +export const CHAT_CONTENT_WIDTH_CSS = + `min(100%, clamp(${CHAT_CONTENT_WIDTH_MIN_PX}px, ${CHAT_CONTENT_WIDTH_VIEWPORT_RATIO * 100}vw, ${CHAT_CONTENT_WIDTH_MAX_PX}px))`; + +/** + * JS half of the token, for layout maths that cannot read a CSS `clamp()` + * (the chat pane's floating-pane reserve). Same inputs, same answer. + */ +export function resolveChatContentWidthPx( + availableWidthPx: number, + viewportWidthPx?: number, +): number { + const viewport = Number.isFinite(viewportWidthPx) && (viewportWidthPx ?? 0) > 0 + ? (viewportWidthPx as number) + : (typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : availableWidthPx); + const target = Math.min( + CHAT_CONTENT_WIDTH_MAX_PX, + Math.max(CHAT_CONTENT_WIDTH_MIN_PX, viewport * CHAT_CONTENT_WIDTH_VIEWPORT_RATIO), + ); + return availableWidthPx > 0 ? Math.min(availableWidthPx, target) : target; +} + /** CSS vars for transcript + composer (scoped under `[data-chat-appearance-root]`). */ export function buildChatAppearanceRootStyle(params: { chatFontSizePx: number; @@ -64,6 +106,11 @@ export function buildChatAppearanceRootStyle(params: { return { ["--chat-font-size" as string]: `${params.chatFontSizePx}px`, + ["--chat-content-width" as string]: CHAT_CONTENT_WIDTH_CSS, + // `--chat-column` predates the token and is read by the composer and the + // pane's draft-launch rows. Aliasing it here keeps composer and transcript + // on ONE width instead of two independent definitions. + ["--chat-column" as string]: "var(--chat-content-width)", ["--chat-row-gap" as string]: `${gap}px`, ["--chat-timeline-pad-x" as string]: `${padX}px`, ["--chat-timeline-pad-top" as string]: `${padTop}px`, diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts new file mode 100644 index 000000000..0aa03c193 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + firstMeaningfulSummary, + formatScheduledRunAt, + humanizeAgentIdentity, + isPlaceholderSummary, +} from "./chatCardPrimitives"; + +describe("humanizeAgentIdentity", () => { + // `/ROOT/SHIP_POLL_927` is Codex's internal agent path. Rendered raw (and + // CSS-uppercased on top) it shouted a file path where a role belonged. + it("turns an internal agent path into a role plus an issue reference", () => { + expect(humanizeAgentIdentity("/ROOT/SHIP_POLL_927")).toEqual({ + label: "Ship poll", + ref: "#927", + raw: "/ROOT/SHIP_POLL_927", + }); + expect(humanizeAgentIdentity("/root/review_fixer")?.raw).toBe("/root/review_fixer"); + expect(humanizeAgentIdentity("/root/review_fixer")?.label).toBe("Review fixer"); + expect(humanizeAgentIdentity("/root/review_fixer")?.ref).toBeNull(); + }); + + it("renders no chip for the runtimes that never set an agent type", () => { + // OpenCode / Droid send nothing at all. + expect(humanizeAgentIdentity(null)).toBeNull(); + expect(humanizeAgentIdentity(undefined)).toBeNull(); + expect(humanizeAgentIdentity(" ")).toBeNull(); + // `background` is already its own chip on the spawn card. + expect(humanizeAgentIdentity("background")).toBeNull(); + // A bare path root carries no role. + expect(humanizeAgentIdentity("/root")).toBeNull(); + }); + + it("passes an already-human agent type through as sentence case", () => { + expect(humanizeAgentIdentity("Explore")?.label).toBe("Explore"); + expect(humanizeAgentIdentity("claude")?.label).toBe("Claude"); + }); + + it("does not mistake a version-like tail for an issue number", () => { + expect(humanizeAgentIdentity("worker_5")?.label).toBe("Worker 5"); + expect(humanizeAgentIdentity("worker_5")?.ref).toBeNull(); + }); +}); + +describe("firstMeaningfulSummary", () => { + it("rejects the runtime filler that used to be printed as a result", () => { + expect(isPlaceholderSummary("Agent completed")).toBe(true); + expect(isPlaceholderSummary("Agent received input")).toBe(true); + expect(isPlaceholderSummary("Agent active.")).toBe(true); + expect(isPlaceholderSummary("")).toBe(true); + }); + + it("returns the first candidate that actually says something", () => { + expect(firstMeaningfulSummary("Agent completed", "Head ccce46c4b is stable.")) + .toBe("Head ccce46c4b is stable."); + expect(firstMeaningfulSummary("Agent completed", null)).toBeNull(); + }); +}); + +describe("formatScheduledRunAt", () => { + const now = Date.parse("2026-07-28T12:13:18.016Z"); + + it("formats a schedule as a relative distance plus a local clock", () => { + const formatted = formatScheduledRunAt("2026-07-28T12:17:18.016Z", now); + expect(formatted).toContain("runs in 4m"); + // Never the raw ISO string. + expect(formatted).not.toContain("2026-07-28T"); + }); + + it("handles hours, days and the past", () => { + expect(formatScheduledRunAt("2026-07-28T14:43:18.016Z", now)).toContain("runs in 2h 30m"); + expect(formatScheduledRunAt("2026-07-30T12:13:18.016Z", now)).toContain("runs in 2d"); + expect(formatScheduledRunAt("2026-07-28T12:08:18.016Z", now)).toContain("ran 5m ago"); + }); + + it("returns null rather than echoing an unparseable value", () => { + expect(formatScheduledRunAt(null)).toBeNull(); + expect(formatScheduledRunAt("")).toBeNull(); + expect(formatScheduledRunAt("not-a-date")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx new file mode 100644 index 000000000..8e0eadcb4 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx @@ -0,0 +1,569 @@ +import React from "react"; +import { + CaretRight, + CheckCircle, + Circle, + Cube, + Prohibit, + SpinnerGap, + WarningCircle, + XCircle, + type Icon as PhosphorIcon, +} from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; +import type { ComputerUseArtifactView } from "../../../shared/types"; + +/** + * The chat transcript's card primitives. + * + * One layout idea holds the whole transcript together: every card row is a + * `[16px glyph] [flexible content] [auto meta]` grid, so titles, durations and + * counts line up in columns down the entire thread instead of each card + * inventing its own indent. Cards compose `CardRow` + one of the `CARD_SKIN` + * shells; nothing here reaches for a bespoke flex arrangement. + * + * Two house rules are enforced here rather than restated per card: + * + * 1. **Width.** `CHAT_CARD_WIDTH_CLASS` is the ONLY max-width a transcript row + * may use. It resolves `--chat-content-width` (see `chatAppearance.ts`), the + * single viewport-scaling token every row — prose, cards, pills, plan, + * files-changed — shares. Card-local clamps are what made cards stop ~26% + * short of the prose they sat under. + * 2. **No red.** A failure is amber (`warn`), never a red error block — the same + * policy stated at the top of `../../../shared/adeCard.ts` and + * `./SubagentActivityCards.tsx`. + */ + +/** The single transcript content width. Never re-clamp a row on top of this. */ +export const CHAT_CARD_WIDTH_CLASS = "w-full max-w-[var(--chat-content-width,52rem)]"; + +/** Type scale, `--chat-font-size`-relative so the appearance setting still drives it. */ +export const CHAT_CARD_TITLE_TEXT = "text-[length:calc(var(--chat-font-size)*12/14)]"; +export const CHAT_CARD_BODY_TEXT = "text-[length:calc(var(--chat-font-size)*10.5/14)]"; +export const CHAT_CARD_META_TEXT = "text-[length:calc(var(--chat-font-size)*10/14)]"; +export const CHAT_CARD_MICRO_TEXT = "text-[length:calc(var(--chat-font-size)*9.5/14)]"; + +/** Radius 10px at the default 16px card radius — the house `-6px` idiom. */ +const CARD_RADIUS = "rounded-[calc(var(--chat-radius-card)-6px)]"; + +export type ChatCardTone = "ok" | "warn" | "running" | "idle" | "neutral"; + +/** Status tone → glyph + colour. One vocabulary for every card. */ +export const CHAT_CARD_TONE: Record = { + ok: { cls: "text-emerald-300/85", Icon: CheckCircle }, + warn: { cls: "text-amber-300/85", Icon: XCircle }, + running: { cls: "text-[color:var(--chat-accent)]", Icon: SpinnerGap }, + idle: { cls: "text-fg/32", Icon: Prohibit }, + neutral: { cls: "text-fg/45", Icon: Circle }, +}; + +export type ChatCardSkin = "line" | "inset" | "bordered" | "rail" | "plain"; + +/** Card shells. `line` is the default — no box, just a hairline rule. */ +export const CHAT_CARD_SKIN: Record = { + line: "px-0.5 py-2 border-b border-white/[0.06] last:border-b-0", + inset: `px-3 py-2.5 bg-white/[0.03] ${CARD_RADIUS}`, + bordered: `px-3 py-2.5 bg-white/[0.025] border border-white/[0.07] ${CARD_RADIUS}`, + rail: `px-3 py-2.5 bg-white/[0.03] border-l-2 rounded-r-[calc(var(--chat-radius-card)-6px)]`, + plain: "py-1", +}; + +const RAIL_BORDER_COLOR: Record = { + ok: "rgb(110 231 183 / 0.5)", + warn: "rgb(252 211 77 / 0.5)", + running: "var(--chat-accent)", + idle: "rgb(255 255 255 / 0.12)", + neutral: "rgb(255 255 255 / 0.12)", +}; + +export function ChatCard({ + skin = "line", + tone = "neutral", + className, + children, + ...rest +}: { + skin?: ChatCardSkin; + tone?: ChatCardTone; + className?: string; + children: React.ReactNode; +} & Omit, "children" | "className">) { + return ( +
+ {children} +
+ ); +} + +/** + * The single layout primitive every card uses: + * `[16px glyph] [flexible content] [auto meta]`. Keeping this grid identical + * everywhere is what makes a scrolled transcript read as columns instead of a + * ragged stack. + */ +export function ChatCardRow({ + tone = "neutral", + icon, + children, + meta, + action, + align = "center", + className, +}: { + tone?: ChatCardTone; + /** Overrides the tone's default glyph (e.g. a merge or clock icon). */ + icon?: PhosphorIcon; + children?: React.ReactNode; + meta?: React.ReactNode; + action?: React.ReactNode; + align?: "center" | "top"; + className?: string; +}) { + const toneSpec = CHAT_CARD_TONE[tone] ?? CHAT_CARD_TONE.neutral; + const Icon = icon ?? toneSpec.Icon; + return ( +
+ + + +
{children}
+
+ {meta != null && meta !== "" ? ( + {meta} + ) : null} + {action} +
+
+ ); +} + +export function ChatCardTitle({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function ChatCardSub({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
; +} + +/** Secondary half of a title — "Merged #927 → main", where the tail is faint. */ +export function ChatCardFaint({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function ChatCardChip({ + tone = "neutral", + title, + children, +}: { + tone?: ChatCardTone; + title?: string; + children: React.ReactNode; +}) { + const cls = { + ok: "text-emerald-300/85 border-emerald-300/25", + warn: "text-amber-300/85 border-amber-300/25", + running: "text-[color:var(--chat-accent)] border-[color:color-mix(in_srgb,var(--chat-accent)_30%,transparent)]", + idle: "text-fg/40 border-white/[0.07]", + neutral: "text-fg/48 border-white/[0.07]", + }[tone]; + return ( + + {children} + + ); +} + +/** Text-weight affordance ("diff ›", "open all ›") that sits in a row's meta column. */ +export function ChatCardAction({ + children, + onClick, + title, +}: { + children: React.ReactNode; + onClick?: () => void; + title?: string; +}) { + return ( + + ); +} + +export function ChatCardButton({ + primary, + children, + onClick, + title, +}: { + primary?: boolean; + children: React.ReactNode; + onClick?: () => void; + title?: string; +}) { + return ( + + ); +} + +export type ChatCardProgress = { passed: number; failed: number; running: number; queued: number }; + +/** Segmented progress hairline — passed / failed / running / queued. */ +export function ChatCardMeter({ progress, className }: { progress: ChatCardProgress; className?: string }) { + const segments: Array<[keyof ChatCardProgress, string]> = [ + ["passed", "bg-emerald-400/70"], + ["failed", "bg-amber-400/80"], + ["running", "bg-[color:var(--chat-accent)]"], + ["queued", "bg-white/12"], + ]; + const total = segments.reduce((sum, [bucket]) => sum + Math.max(0, progress[bucket]), 0); + if (total <= 0) return null; + return ( +
+ {segments.map(([bucket, fill]) => { + const value = Math.max(0, progress[bucket]); + if (value <= 0) return null; + return ; + })} +
+ ); +} + +/** + * Detail rows hang under a card head, separated by a hairline. `path` flips the + * text direction so a truncated file path keeps its filename visible. + */ +export function ChatCardDetail({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
; +} + +export function ChatCardDetailRow({ + tone = "neutral", + label, + value, + strike, + path, + title, + onClick, +}: { + tone?: ChatCardTone; + label: React.ReactNode; + value?: React.ReactNode; + strike?: boolean; + /** RTL truncation — keeps the tail (the filename) of a long path readable. */ + path?: boolean; + title?: string; + onClick?: () => void; +}) { + const toneSpec = CHAT_CARD_TONE[tone] ?? CHAT_CARD_TONE.neutral; + const Icon = toneSpec.Icon; + const body = ( + <> + + + + + {label} + + {value} + + ); + const className = cn( + "grid grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-2 py-[3px]", + CHAT_CARD_BODY_TEXT, + ); + if (!onClick) { + return ( +
+ {body} +
+ ); + } + return ( + + ); +} + +export function ChatCardDiffStat({ additions, deletions }: { additions: number; deletions: number }) { + return ( + + +{additions.toLocaleString()} + −{deletions.toLocaleString()} + + ); +} + +/** + * A centred hairline rule with a mono cutout — the transcript's turn separator + * (`10:04 · ran 3m 32s`). + */ +export function ChatTurnRule({ label, children }: { label?: React.ReactNode; children?: React.ReactNode }) { + return ( +
+ + {label} + {children} + +
+ ); +} + +/* ── Proof ──────────────────────────────────────────────────────────────── */ + +/** + * Collapsible proof filmstrip: a `▣ Proof · N` head plus a horizontally + * scrollable thumbnail strip. Rendered inline at the point of capture — proof + * is a chronological transcript row, not a thread footer. + * + * Exported for the proof drawer to reuse; it owns no artifact loading of its + * own beyond the image `src` each caller supplies. + */ +export function ChatProofFilmstrip({ + artifacts, + title = "Proof", + defaultOpen = true, + onOpenAll, + onOpenArtifact, + resolveThumbnailSrc, +}: { + artifacts: ComputerUseArtifactView[]; + title?: string; + defaultOpen?: boolean; + onOpenAll?: () => void; + onOpenArtifact?: (artifact: ComputerUseArtifactView) => void; + /** Returns a renderable image URL, or null when the artifact has no preview. */ + resolveThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; +}) { + const [open, setOpen] = React.useState(defaultOpen); + if (!artifacts.length) return null; + + return ( + + open all : null} + > + + + {open ? ( +
+ {artifacts.map((artifact) => { + const broken = artifact.availability != null && artifact.availability !== "available"; + const src = broken ? null : (resolveThumbnailSrc?.(artifact) ?? null); + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +/* ── Text helpers ───────────────────────────────────────────────────────── */ + +/** Codex filler that must never be printed where a real result belongs. */ +const PLACEHOLDER_SUMMARIES = new Set([ + "agent completed", + "agent received input", + "agent active", + "agent started", + "agent running", + "agent finished", + "agent stopped", +]); + +export function isPlaceholderSummary(value: string | null | undefined): boolean { + const text = (value ?? "").trim().toLowerCase().replace(/[.!]+$/, ""); + if (!text) return true; + return PLACEHOLDER_SUMMARIES.has(text); +} + +/** + * First non-placeholder candidate, or null. Callers fall back to the status + * word rather than printing "Agent completed" where a result should be. + */ +export function firstMeaningfulSummary(...candidates: Array): string | null { + for (const candidate of candidates) { + const text = candidate?.trim(); + if (text && !isPlaceholderSummary(text)) return text; + } + return null; +} + +export type AgentIdentityLabel = { + /** Human role, sentence case — "Ship poller". */ + label: string; + /** Trailing issue/PR number lifted out of the path, e.g. "#927". */ + ref: string | null; + /** The raw value, kept for the `title` tooltip. */ + raw: string; +}; + +/** + * Turn a runtime's internal agent path into a role. + * + * Codex hands us `/ROOT/SHIP_POLL_927` — an id, not an identity. Identity + * should read as role + intent, so the last segment becomes sentence case + * ("Ship poll") and a trailing issue/PR number is lifted into its own chip. + * Runtimes that never set an agent type (OpenCode, Droid) get `null` and + * render no chip at all. + */ +export function humanizeAgentIdentity(value: string | null | undefined): AgentIdentityLabel | null { + const raw = (value ?? "").trim(); + if (!raw) return null; + // `background` is a flag the spawn card already renders as its own chip. + if (raw.toLowerCase() === "background") return null; + const segments = raw.split(/[/\\]+/).filter((segment) => segment.length > 0); + let tail = segments.length ? segments[segments.length - 1]! : raw; + // A bare "/root" (or an all-separator value) carries no role at all. + if (segments.length === 1 && /^root$/i.test(tail)) return null; + let ref: string | null = null; + const numberMatch = tail.match(/[_\-\s](\d{2,})$/); + if (numberMatch) { + ref = `#${numberMatch[1]}`; + tail = tail.slice(0, numberMatch.index); + } + const words = tail.split(/[_\-\s]+/).filter((word) => word.length > 0); + if (!words.length) return ref ? { label: raw, ref, raw } : null; + const joined = words.join(" ").toLowerCase(); + const label = joined.charAt(0).toUpperCase() + joined.slice(1); + return { label, ref, raw }; +} + +/** + * `runs in 4m · 12:17` — a schedule is only actionable as a relative distance + * plus a local wall clock; a raw ISO string is neither. + */ +export function formatScheduledRunAt(value: string | null | undefined, nowMs = Date.now()): string | null { + const raw = (value ?? "").trim(); + if (!raw) return null; + const at = Date.parse(raw); + if (!Number.isFinite(at)) return null; + const clock = new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const deltaMs = at - nowMs; + const absMinutes = Math.round(Math.abs(deltaMs) / 60_000); + const relative = absMinutes < 1 + ? (deltaMs >= 0 ? "runs now" : "ran just now") + : absMinutes < 60 + ? (deltaMs >= 0 ? `runs in ${absMinutes}m` : `ran ${absMinutes}m ago`) + : absMinutes < 60 * 24 + ? (() => { + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + const span = minutes ? `${hours}h ${minutes}m` : `${hours}h`; + return deltaMs >= 0 ? `runs in ${span}` : `ran ${span} ago`; + })() + : (() => { + const days = Math.round(absMinutes / (60 * 24)); + return deltaMs >= 0 ? `runs in ${days}d` : `ran ${days}d ago`; + })(); + return `${relative} · ${clock}`; +} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 21c0ded33..3d32aeba2 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -2958,6 +2958,93 @@ describe("ade_card transcript rows", () => { expect(merged.metrics).toEqual([{ label: "files", value: "1" }]); }); + // `buildPrCiCard` ALWAYS writes `rows` and `progress`, so the "omitted means + // partial patch" comment was not enough on its own: a poll that came back + // from a rate-limited GitHub used to overwrite a good card with `rows: []` + // and an all-zero progress bar. + it("preserves prior detail — and marks it stale — when a degraded re-emit lands", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ + variant: "pr_ci", + rows: [{ icon: "fail", text: "test-desktop" }], + progress: { passed: 28, failed: 2, running: 0, queued: 0 }, + metrics: [{ label: "failed", value: "2" }], + })), + env("2026-07-27T10:00:01.000Z", card({ + variant: "pr_ci", + rows: [], + progress: { passed: 0, failed: 0, running: 0, queued: 0 }, + metrics: [], + degradedReason: "HTTP 403: rate limited", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + })), + ]); + + expect(rows).toHaveLength(1); + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.rows).toEqual([{ icon: "fail", text: "test-desktop" }]); + expect(merged.progress).toEqual({ passed: 28, failed: 2, running: 0, queued: 0 }); + expect(merged.metrics).toEqual([{ label: "failed", value: "2" }]); + expect(merged.stale).toBe(true); + expect(merged.degradedReason).toBe("HTTP 403: rate limited"); + expect(merged.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); + }); + + it("clears stale degradation state and retry actions as soon as healthy detail returns", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ rows: [{ icon: "fail", text: "lint" }] })), + env("2026-07-27T10:00:01.000Z", card({ + rows: [], + degradedReason: "HTTP 403", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + })), + env("2026-07-27T10:00:02.000Z", card({ rows: [{ icon: "pass", text: "lint" }] })), + ]); + + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.stale).toBe(false); + expect(merged.rows).toEqual([{ icon: "pass", text: "lint" }]); + expect(merged.degradedReason).toBeUndefined(); + expect(merged.actions).toEqual([]); + }); + + it("clears stale degradation state when a healthy detail refresh is genuinely empty", () => { + const emptyProgress = { passed: 0, failed: 0, running: 0, queued: 0 }; + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ + variant: "pr_ci", + rows: [{ icon: "fail", text: "lint" }], + metrics: [{ label: "failed", value: "1" }], + progress: { ...emptyProgress, failed: 1 }, + })), + env("2026-07-27T10:00:01.000Z", card({ + variant: "pr_ci", + rows: [], + metrics: [], + progress: emptyProgress, + degradedReason: "HTTP 403", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + })), + env("2026-07-27T10:00:02.000Z", card({ + variant: "pr_ci", + rows: [], + metrics: [], + progress: emptyProgress, + })), + ]); + + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.stale).toBe(false); + expect(merged.rows).toEqual([]); + expect(merged.metrics).toEqual([]); + expect(merged.progress).toEqual(emptyProgress); + expect(merged.degradedReason).toBeUndefined(); + expect(merged.actions).toEqual([]); + }); + it("keeps distinct cardIds as distinct rows", () => { const rows = collapseChatTranscriptEvents([ env("2026-07-27T10:00:00.000Z", card({ cardId: "run-1" })), diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index e5cd67cd1..3f6d23ed0 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -15,7 +15,7 @@ import { type NormalizedSubagentLifecycleEvent, } from "../../../shared/chatSubagents"; import { backgroundCommandLabel } from "../../../shared/chatScheduledWork"; -import { adeCardRowKey } from "../../../shared/adeCard"; +import { adeCardProgressTotal, adeCardRowKey } from "../../../shared/adeCard"; import { contextCompactMergeKey, isContextCompactionChatEvent, @@ -1040,6 +1040,61 @@ function resolveAdeCardRowIndex( return null; } +type AdeCardEvent = Extract; + +/** + * Merge a repeat `ade_card` emit into the row that is already in the transcript. + * + * A re-emit is a PATCH, not a replacement — but the emitters do not honour that + * on their own: `buildPrCiCard` always writes `rows` and `progress`, so a poll + * that came back from a rate-limited GitHub used to overwrite a good card with + * `rows: []` and an all-zero progress bar. A card that has already shown real + * detail therefore keeps it whenever the incoming payload has none, and is + * flagged `stale` so the surface can say "this is the last thing we knew" + * rather than silently showing old numbers as current. + * + * The flag clears itself the moment a healthy detail refresh lands, including + * a successful refresh whose rows and totals are genuinely empty. + */ +function mergeAdeCardEvent( + existing: AdeCardEvent, + incoming: AdeCardEvent, + cardId: string, +): AdeCardEvent { + const merged: AdeCardEvent = { ...existing, ...incoming, cardId }; + + const incomingRows = incoming.rows ?? []; + const incomingMetrics = incoming.metrics ?? []; + const incomingProgressTotal = adeCardProgressTotal(incoming.progress); + const existingHadDetail = (existing.rows?.length ?? 0) > 0 + || (existing.metrics?.length ?? 0) > 0 + || adeCardProgressTotal(existing.progress) > 0; + const incomingIsDetailRefresh = incoming.rows !== undefined + || incoming.metrics !== undefined + || incoming.progress !== undefined; + + if (existingHadDetail && incoming.degradedReason) { + if (!incomingRows.length && existing.rows?.length) merged.rows = existing.rows; + if (!incomingMetrics.length && existing.metrics?.length) merged.metrics = existing.metrics; + if (incomingProgressTotal === 0 && existing.progress) merged.progress = existing.progress; + if (existing.rowsTruncated != null && incoming.rowsTruncated == null) { + merged.rowsTruncated = existing.rowsTruncated; + } + merged.stale = true; + return merged; + } + + // Healthy emit: drop every degradation-only field the previous failed fetch + // left behind. Full healthy card payloads omit the retry action and reason, + // so the initial spread cannot distinguish recovery from a partial patch. + if (incomingIsDetailRefresh && !incoming.degradedReason) { + merged.stale = incoming.stale ?? false; + merged.degradedReason = incoming.degradedReason ?? undefined; + merged.actions = incoming.actions ?? []; + } + return merged; +} + function removeCollapsedTranscriptRow( rows: ChatTranscriptRenderEnvelope[], context: CollapseTranscriptContext, @@ -1874,9 +1929,7 @@ export function appendCollapsedChatTranscriptEvent( rows[existingIndex] = { key, timestamp: envelope.timestamp, - // Merge, don't replace: an update that omits `rows`/`metrics` is a - // partial patch, not an instruction to blank them. - event: { ...existing.event, ...event, cardId }, + event: mergeAdeCardEvent(existing.event, event, cardId), }; context?.adeCardRowIndexById.set(cardId, existingIndex); return; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.test.tsx new file mode 100644 index 000000000..a96d59020 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.test.tsx @@ -0,0 +1,33 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CodexPlanCard } from "./CodexPlanCard"; + +afterEach(cleanup); + +describe("CodexPlanCard", () => { + it("does not open chat info when the nested details toggle handles the keyboard", () => { + const onOpenInfo = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("button", { name: "live" }); + fireEvent.keyDown(toggle, { key: "Enter" }); + expect(onOpenInfo).not.toHaveBeenCalled(); + + fireEvent.keyDown(toggle, { key: " " }); + expect(onOpenInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx index 7976c3541..bcca34a3f 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx @@ -1,8 +1,17 @@ import { useState } from "react"; import { motion } from "motion/react"; +import { ListChecks } from "@phosphor-icons/react"; import type { AgentChatEvent, AgentChatPlanStep } from "../../../../shared/types"; import { cn } from "../../ui/cn"; import { ChatMarkdown } from "../chatMarkdown"; +import { + CHAT_CARD_WIDTH_CLASS, + ChatCardDetail, + ChatCardDetailRow, + ChatCardRow, + ChatCardTitle, + type ChatCardTone, +} from "../chatCardPrimitives"; type PlanEvent = Extract; @@ -11,8 +20,6 @@ type CodexPlanCardProps = { onOpenInfo?: () => void; }; -const VIOLET = "#A78BFA"; - function PlanMarkdown({ markdown }: { markdown: string }) { return (
@@ -21,27 +28,12 @@ function PlanMarkdown({ markdown }: { markdown: string }) { ); } -function PlanGlyph({ status }: { status: AgentChatPlanStep["status"] }) { - if (status === "in_progress") { - return ( - - {"◐"} - - ); - } - if (status === "completed") { - return ( - {"●"} - ); - } - if (status === "failed") { - return ( - {"✕"} - ); - } - return ( - {"○"} - ); +/** Step status → the shared tone vocabulary. A failed step is amber, not red. */ +function planStepTone(status: AgentChatPlanStep["status"]): ChatCardTone { + if (status === "completed") return "ok"; + if (status === "in_progress") return "running"; + if (status === "failed") return "warn"; + return "neutral"; } export function CodexPlanCard({ event, onOpenInfo }: CodexPlanCardProps) { @@ -75,71 +67,66 @@ export function CodexPlanCard({ event, onOpenInfo }: CodexPlanCardProps) { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.14, ease: "easeOut" }} className={cn( - "relative overflow-hidden rounded-xl border border-violet-400/15 bg-violet-500/[0.025]", - onOpenInfo && "cursor-pointer transition-colors hover:border-violet-300/25 hover:bg-violet-500/[0.04] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-violet-300/45", + // Checklist shape: one head row, one detail row per step. Same + // `[glyph | content | meta]` grid and same width token as every other + // transcript card. + CHAT_CARD_WIDTH_CLASS, + "relative overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] bg-white/[0.03] px-3 py-2.5", + onOpenInfo && "cursor-pointer transition-colors hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-violet-300/45", )} > -
-

- {stateLabel} -

- {steps.length ? ( - - {steps.filter((s) => s.status === "completed").length} - / - {steps.length} - + s.status === "completed").length}/${steps.length}` : null} + > + {stateLabel} + {event.explanation ? ( +

+ {event.explanation} +

) : null} -
- - {event.explanation ? ( -

- {event.explanation} -

- ) : null} + {steps.length ? ( -
    - {steps.map((step, idx) => { - const isActive = step.status === "in_progress"; - const isComplete = step.status === "completed"; - return ( -
  • - - - - {step.text} -
  • - ); - })} -
+ + {steps.map((step, idx) => ( + + ))} + ) : !hasStreaming ? ( -
+
Drafting steps…
) : null} {showCompletedMarkdownInline ? ( -
+
) : null} {showMarkdownToggle ? ( -
+