diff --git a/.qwen/e2e-tests/electron-desktop/rich-tool-call-activity-cards.md b/.qwen/e2e-tests/electron-desktop/rich-tool-call-activity-cards.md new file mode 100644 index 000000000..44f034a1b --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/rich-tool-call-activity-cards.md @@ -0,0 +1,47 @@ +# Rich Tool-Call Activity Cards + +- Slice date: 2026-04-26 +- Executable harness: `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- Command: + `cd packages/desktop && npm run e2e:cdp` +- Result: pass +- Artifact directory: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-57-31-788Z/` + +## Scenario + +1. Launch the real Electron app with isolated HOME, runtime, user-data, and a + fake dirty Git workspace. +2. Open the fake project through the desktop directory picker path. +3. Send the first composer prompt and approve the fake command request. +4. Wait for the fake ACP resolved tool update. +5. Assert the conversation renders a compact tool activity card with command + title, status, command input, output summary, and file chip. +6. Continue the existing changed-files, review, settings, terminal, and final + layout smoke path. + +## Assertions + +- The resolved activity card is inside the chat timeline and stays above the + composer without overlap. +- The card includes `Run desktop E2E command`, `completed`, + `printf desktop-e2e`, `desktop-e2e command completed`, and `README.md:1`. +- The card does not show the fake tool call ID or session ID. +- Legacy `.chat-tool` rows are absent. +- Console errors: 0. +- Failed local network requests: 0. + +## Artifacts + +- `resolved-tool-activity.json` +- `resolved-tool-activity.png` +- `conversation-changes-summary.json` +- `completed-workspace.png` +- `electron.log` +- `summary.json` + +## Known Uncovered Risk + +The harness covers deterministic fake ACP tool updates with one file reference +and bounded string output. Live ACP tools with richer structured outputs, many +file references, and long command output still need broader coverage. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index ec013200f..3b7b113df 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,99 @@ execution order, verification, decisions, and remaining work. ## Codex Alignment Progress +### Completed Slice: Rich Tool-Call Activity Cards + +Status: completed in iteration 10. + +Goal: make completed and in-progress tool calls read as useful task activity +inside the conversation instead of a sparse tool row. + +User-visible value: users can see what the agent did, what command/input was +used, which files were referenced, and whether the tool completed or failed +without reading ACP IDs or opening diagnostics. + +Expected files: + +- `packages/desktop/src/main/acp/createE2eAcpClient.ts` +- `packages/desktop/src/renderer/components/layout/ChatThread.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` +- `packages/desktop/src/renderer/stores/chatStore.test.ts` +- `packages/desktop/src/renderer/styles.css` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/rich-tool-call-activity-cards.md` +- `design/qwen-code-electron-desktop-implementation-plan.md` + +Acceptance criteria: + +- Tool calls render as compact inline conversation activity cards with kind, + title, status, and stable `data-testid` hooks. +- Tool cards show a bounded command/input preview when safe user-facing input + is present. +- Completed or failed tool cards show a bounded output/result summary without + exposing request/session IDs. +- File locations render as compact chips with path and optional line number. +- The previous generic `.chat-tool` row no longer appears for tool activity. +- Cards stay within the timeline and do not overlap the composer in real + Electron. + +Verification: + +- Unit/component test command: + `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/stores/chatStore.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` +- Build/typecheck/lint commands: + `cd packages/desktop && npm run typecheck && npm run lint && npm run build` +- Real Electron harness: + `cd packages/desktop && npm run e2e:cdp` +- Harness path: `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- E2E scenario steps: launch real Electron with isolated HOME/runtime/user-data + and fake ACP, open the fake Git project, send from the composer, approve the + fake command request, then assert the resolved tool activity card includes + command title, status, command preview, output summary, and file chips before + continuing the existing review/settings/terminal smoke path. +- E2E assertions: activity card is present after approval, uses compact + geometry inside the chat timeline, contains `README.md:1`, does not render + the raw tool call ID or session ID, no legacy `.chat-tool` node remains, and + console errors/failed local requests are absent. +- Diagnostic artifacts: CDP screenshots, rich tool-call JSON, conversation + summary JSON, Electron log, summary JSON under + `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for prototype-constrained compact + activity-card hierarchy and file chip density; `electron-desktop-dev` for + renderer changes and real Electron CDP verification; `brainstorming` applied + by deriving the slice from the repo plan and immutable prototype instead of + asking ordinary product questions during the autonomous loop. + +Notes and decisions: + +- The prototype keeps agent activity in the reading flow, so this slice + replaces the generic tool row with an inline card rather than adding another + panel. +- The card intentionally surfaces title/kind/status, bounded input/output, and + file locations only. ACP request IDs, session IDs, and transport details stay + out of the main conversation. +- The fake ACP path will emit deterministic location/output data so the CDP + harness can assert a real user-visible resolved tool card. + +Verification results: + +- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/stores/chatStore.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` + passed with 13 tests. +- `cd packages/desktop && npm run typecheck` passed. +- `cd packages/desktop && npm run lint` passed. +- `cd packages/desktop && npm run build` passed. +- `cd packages/desktop && npm run e2e:cdp` passed after launch through real + Electron over CDP. +- Passing artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-57-31-788Z/`. + +Next work: + +- Continue rich conversation primitives by adding assistant message action rows + for copy/retry/open changed files and by turning file references in assistant + prose into compact open/reveal chips. +- Tighten tool-card density at compact viewport widths after adding a second + fake ACP scenario with multiple file references and longer command output. + ### Completed Slice: Inline Command Approval Cards Status: completed in iteration 9. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 002e1995e..d27ac7578 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -77,6 +77,8 @@ async function main() { await saveScreenshot('inline-command-approval.png'); await clickButton('Approve Once'); await waitForText('E2E fake ACP response received'); + await assertResolvedToolActivity('resolved-tool-activity.json'); + await saveScreenshot('resolved-tool-activity.png'); await assertConversationChangesSummary('conversation-changes-summary.json'); await waitForSelector('[data-testid="thread-list"]'); @@ -775,6 +777,106 @@ async function assertInlineCommandApproval(fileName) { } } +async function assertResolvedToolActivity(fileName) { + await waitForSelector('[data-testid="conversation-tool-card"]'); + const snapshot = await evaluate(`(() => { + const card = document.querySelector( + '[data-testid="conversation-tool-card"]' + ); + const timeline = document.querySelector('.chat-timeline'); + const composer = document.querySelector('[data-testid="message-composer"]'); + const rectFor = (element) => { + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height + }; + }; + return { + bodyText: document.body.innerText, + cardText: card?.innerText ?? '', + cardRect: rectFor(card), + timelineRect: rectFor(timeline), + composerRect: rectFor(composer), + legacyToolRows: document.querySelectorAll('.chat-tool').length, + fileChipText: + document.querySelector('.conversation-tool-files')?.innerText ?? '' + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + const cardText = snapshot.cardText.toLowerCase(); + for (const expectedText of [ + 'run desktop e2e command', + 'completed', + 'printf desktop-e2e', + 'desktop-e2e command completed', + ]) { + if (!cardText.includes(expectedText)) { + throw new Error( + `Resolved tool activity is missing ${expectedText}: ${snapshot.cardText}`, + ); + } + } + + if (!snapshot.fileChipText.includes('README.md:1')) { + throw new Error( + `Resolved tool activity is missing the file chip: ${snapshot.fileChipText}`, + ); + } + + for (const internalText of ['e2e-terminal-check', 'session-e2e']) { + if (snapshot.cardText.includes(internalText)) { + throw new Error( + `Resolved tool activity leaked internal text ${internalText}: ${snapshot.cardText}`, + ); + } + } + + if (snapshot.legacyToolRows !== 0) { + throw new Error( + `Resolved tool activity should not render legacy rows: ${snapshot.legacyToolRows}`, + ); + } + + if (!snapshot.cardRect || !snapshot.timelineRect || !snapshot.composerRect) { + throw new Error( + `Resolved tool activity geometry is missing: ${JSON.stringify(snapshot)}`, + ); + } + + if (snapshot.cardRect.width < 360 || snapshot.cardRect.height > 240) { + throw new Error( + `Resolved tool activity geometry is unexpected: ${JSON.stringify( + snapshot.cardRect, + )}`, + ); + } + + if ( + snapshot.cardRect.left < snapshot.timelineRect.left || + snapshot.cardRect.right > snapshot.timelineRect.right + 1 + ) { + throw new Error('Resolved tool activity should stay inside the timeline.'); + } + + if (snapshot.cardRect.bottom > snapshot.composerRect.top) { + throw new Error('Resolved tool activity overlaps the composer.'); + } +} + async function assertReviewDrawerLayout(fileName) { const metrics = await evaluate(`(() => { const rectFor = (selector) => { diff --git a/packages/desktop/src/main/acp/createE2eAcpClient.ts b/packages/desktop/src/main/acp/createE2eAcpClient.ts index 956e0b46e..76f331044 100644 --- a/packages/desktop/src/main/acp/createE2eAcpClient.ts +++ b/packages/desktop/src/main/acp/createE2eAcpClient.ts @@ -168,7 +168,12 @@ export class E2eAcpClient implements AcpSessionClient { permission.outcome.optionId !== 'deny' ? 'completed' : 'failed', - rawOutput: permission.outcome.outcome, + rawInput: 'printf desktop-e2e', + rawOutput: + permission.outcome.outcome === 'selected' + ? 'desktop-e2e command completed' + : permission.outcome.outcome, + locations: [{ path: 'README.md', line: 1 }], }); this.emit(sessionId, { sessionUpdate: 'agent_message_chunk', diff --git a/packages/desktop/src/renderer/components/layout/ChatThread.tsx b/packages/desktop/src/renderer/components/layout/ChatThread.tsx index dab040b85..cccd881d0 100644 --- a/packages/desktop/src/renderer/components/layout/ChatThread.tsx +++ b/packages/desktop/src/renderer/components/layout/ChatThread.tsx @@ -294,13 +294,7 @@ function TimelineItem({ item }: { item: ChatTimelineItem }) { } if (item.type === 'tool') { - return ( -
-
{item.toolCall.kind || 'tool'}
- {item.toolCall.title || item.toolCall.toolCallId} - {item.toolCall.status ? {item.toolCall.status} : null} -
- ); + return ; } if (item.type === 'plan') { @@ -322,6 +316,60 @@ function TimelineItem({ item }: { item: ChatTimelineItem }) { return
{item.label}
; } +function ToolActivityCard({ + toolCall, +}: { + toolCall: Extract['toolCall']; +}) { + const title = + toolCall.title || formatToolKindTitle(toolCall.kind) || 'Tool activity'; + const kind = toolCall.kind || 'tool'; + const status = toolCall.status || 'running'; + const inputPreview = formatToolInput(toolCall.rawInput); + const outputPreview = formatToolOutput(toolCall.rawOutput); + const fileReferences = getToolFileReferences(toolCall); + const visibleFiles = fileReferences.slice(0, 4); + const hiddenFileCount = Math.max(0, fileReferences.length - 4); + + return ( +
+
+
+ {kind} + {title} +
+ {status} +
+ {inputPreview ? ( +
+ Input +
{inputPreview}
+
+ ) : null} + {visibleFiles.length > 0 ? ( +
    + {visibleFiles.map((file) => ( +
  • + {formatToolFileReference(file)} +
  • + ))} + {hiddenFileCount > 0 ?
  • {hiddenFileCount} more
  • : null} +
+ ) : null} + {outputPreview ? ( +
+ Result +
{outputPreview}
+
+ ) : null} +
+ ); +} + function InlinePendingPrompts({ onAskUserQuestionResponse, onPermissionResponse, @@ -471,17 +519,175 @@ function AskUserQuestionCard({ function formatToolInput(input: unknown): string | null { if (typeof input === 'string') { - return input.length > 0 ? input : null; + return boundToolPreview(input); } - if (input && typeof input === 'object' && 'command' in input) { - const command = (input as { command?: unknown }).command; - return typeof command === 'string' && command.length > 0 ? command : null; + const record = getRecord(input); + if (!record) { + return null; + } + + for (const key of ['command', 'path', 'filePath', 'pattern', 'query']) { + const value = getStringField(record, key); + if (value) { + return boundToolPreview(value); + } } return null; } +function formatToolOutput(output: unknown): string | null { + if (typeof output === 'string') { + return boundToolPreview(output); + } + + if (typeof output === 'number' || typeof output === 'boolean') { + return String(output); + } + + const record = getRecord(output); + if (!record) { + return null; + } + + for (const key of ['output', 'stdout', 'stderr', 'message', 'result']) { + const value = getStringField(record, key); + if (value) { + return boundToolPreview(value); + } + } + + const outcome = getStringField(record, 'outcome'); + return outcome ? boundToolPreview(outcome) : null; +} + +function getToolFileReferences( + toolCall: Extract['toolCall'], +): Array<{ path: string; line?: number | null }> { + const references: Array<{ path: string; line?: number | null }> = []; + + for (const location of toolCall.locations ?? []) { + if (location.path.trim().length > 0) { + references.push({ path: location.path, line: location.line }); + } + } + + const input = getRecord(toolCall.rawInput); + if (input) { + const line = getNumberField(input, 'line'); + for (const key of ['path', 'filePath']) { + const path = getStringField(input, key); + if (path) { + references.push({ path, line }); + } + } + + const paths = input['paths']; + if (Array.isArray(paths)) { + for (const path of paths) { + if (typeof path === 'string' && path.trim().length > 0) { + references.push({ path }); + } + } + } + } + + return dedupeToolFileReferences(references); +} + +function dedupeToolFileReferences( + references: Array<{ path: string; line?: number | null }>, +): Array<{ path: string; line?: number | null }> { + const seen = new Set(); + const unique: Array<{ path: string; line?: number | null }> = []; + + for (const reference of references) { + const key = `${reference.path}:${reference.line ?? ''}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + unique.push(reference); + } + + return unique; +} + +function formatToolFileReference(file: { + path: string; + line?: number | null; +}): string { + return file.line ? `${file.path}:${file.line}` : file.path; +} + +function formatToolKindTitle(kind: string | undefined): string | null { + if (!kind) { + return null; + } + + const normalized = kind.replace(/[-_]+/gu, ' ').trim(); + return normalized.length > 0 + ? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}` + : null; +} + +function getToolStatusClass(status: string): string { + const normalized = status.toLowerCase(); + if ( + normalized.includes('fail') || + normalized.includes('error') || + normalized.includes('cancel') || + normalized.includes('deny') + ) { + return 'conversation-tool-card-danger'; + } + + if ( + normalized.includes('complete') || + normalized.includes('success') || + normalized.includes('done') + ) { + return 'conversation-tool-card-complete'; + } + + return 'conversation-tool-card-running'; +} + +function boundToolPreview(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + return trimmed.length > 240 + ? `${trimmed.slice(0, 237).trimEnd()}...` + : trimmed; +} + +function getRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getStringField( + record: Record, + key: string, +): string | null { + const value = record[key]; + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function getNumberField( + record: Record, + key: string, +): number | null { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + function ChangedFilesSummaryCard({ gitDiff, onOpenReview, diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx index 8d292cb01..80d7f3fc3 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx @@ -356,6 +356,44 @@ describe('WorkspacePage', () => { ); }); + it('renders rich tool activity cards with file references', () => { + const chatState = chatReducer(createInitialChatState(), { + type: 'server_message', + message: { + type: 'tool_call', + data: { + toolCallId: 'internal-tool-123', + kind: 'execute', + title: 'Run focused tests', + status: 'completed', + rawInput: { + command: 'npm test -- WorkspacePage.test.tsx', + sessionId: 'session-should-stay-hidden', + }, + rawOutput: 'tests passed', + locations: [{ path: 'src/renderer/WorkspacePage.tsx', line: 42 }], + }, + }, + }); + + const renderedContainer = renderWorkspace({ chatState }); + const toolCard = renderedContainer.querySelector( + '[data-testid="conversation-tool-card"]', + ); + const toolText = toolCard?.textContent ?? ''; + + expect(toolCard).toBeTruthy(); + expect(toolText).toContain('execute'); + expect(toolText).toContain('Run focused tests'); + expect(toolText).toContain('completed'); + expect(toolText).toContain('npm test -- WorkspacePage.test.tsx'); + expect(toolText).toContain('tests passed'); + expect(toolText).toContain('src/renderer/WorkspacePage.tsx:42'); + expect(toolText).not.toContain('internal-tool-123'); + expect(toolText).not.toContain('session-should-stay-hidden'); + expect(renderedContainer.querySelector('.chat-tool')).toBeNull(); + }); + it('routes terminal output through an attach action', () => { const onAttachTerminalOutput = vi.fn(); const renderedContainer = renderWorkspace({ diff --git a/packages/desktop/src/renderer/stores/chatStore.test.ts b/packages/desktop/src/renderer/stores/chatStore.test.ts index 7c650e934..7e58836e8 100644 --- a/packages/desktop/src/renderer/stores/chatStore.test.ts +++ b/packages/desktop/src/renderer/stores/chatStore.test.ts @@ -115,4 +115,45 @@ describe('chatStore', () => { expect(state.items).toHaveLength(0); expect(JSON.stringify(state.items)).not.toContain('Permission requested'); }); + + it('upserts tool activity without duplicating timeline items', () => { + const pending = chatReducer(createInitialChatState(), { + type: 'server_message', + message: { + type: 'tool_call', + data: { + toolCallId: 'tool-1', + kind: 'read', + title: 'Read file', + status: 'pending', + rawInput: { path: 'README.md' }, + locations: [{ path: 'README.md', line: 1 }], + }, + }, + }); + const completed = chatReducer(pending, { + type: 'server_message', + message: { + type: 'tool_call', + data: { + toolCallId: 'tool-1', + status: 'completed', + rawOutput: 'read complete', + }, + }, + }); + + expect(completed.items).toHaveLength(1); + expect(completed.items[0]).toMatchObject({ + id: 'tool-tool-1', + type: 'tool', + toolCall: { + title: 'Read file', + status: 'completed', + rawInput: { path: 'README.md' }, + rawOutput: 'read complete', + locations: [{ path: 'README.md', line: 1 }], + }, + }); + }); }); diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 9150226db..108040e82 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -684,7 +684,6 @@ summary:focus-visible { } .chat-message, -.chat-tool, .chat-plan { display: grid; width: min(860px, 100%); @@ -712,8 +711,6 @@ summary:focus-visible { } .chat-message p, -.chat-tool strong, -.chat-tool span, .chat-plan ol { margin: 0; } @@ -725,22 +722,6 @@ summary:focus-visible { white-space: pre-wrap; } -.chat-tool { - align-self: center; - border-color: rgba(117, 217, 156, 0.28); -} - -.chat-tool strong { - color: var(--text); - font-size: 14px; -} - -.chat-tool span { - color: #a9eabd; - font-size: 12px; - font-weight: 760; -} - .chat-plan { align-self: center; } @@ -774,6 +755,125 @@ summary:focus-visible { font-size: 12px; } +.conversation-tool-card { + display: grid; + width: min(860px, 100%); + align-self: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid rgba(117, 217, 156, 0.22); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(117, 217, 156, 0.07), transparent), + rgba(238, 244, 239, 0.035); + color: var(--text-soft); +} + +.conversation-tool-card-running { + border-color: rgba(85, 166, 255, 0.26); + background: + linear-gradient(180deg, rgba(85, 166, 255, 0.08), transparent), + rgba(238, 244, 239, 0.035); +} + +.conversation-tool-card-complete { + border-color: rgba(117, 217, 156, 0.26); +} + +.conversation-tool-card-danger { + border-color: rgba(255, 127, 105, 0.34); + background: + linear-gradient(180deg, rgba(255, 127, 105, 0.08), transparent), + rgba(238, 244, 239, 0.035); +} + +.conversation-tool-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.conversation-tool-heading > div, +.conversation-tool-section { + display: grid; + min-width: 0; + gap: 5px; +} + +.conversation-tool-heading strong { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-tool-status { + flex: 0 0 auto; + color: #a9eabd; + font-size: 11px; + font-weight: 820; + text-transform: uppercase; +} + +.conversation-tool-card-running .conversation-tool-status { + color: #9fceff; +} + +.conversation-tool-card-danger .conversation-tool-status { + color: #ff9b8b; +} + +.conversation-tool-section pre { + max-height: 72px; + min-width: 0; + margin: 0; + overflow: auto; + padding: 8px 10px; + border-radius: 6px; + background: rgba(8, 10, 11, 0.3); + color: var(--text); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.conversation-tool-output pre { + color: var(--text-soft); +} + +.conversation-tool-files { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.conversation-tool-files li { + max-width: 260px; + min-height: 24px; + overflow: hidden; + padding: 4px 8px; + border: 1px solid rgba(85, 166, 255, 0.2); + border-radius: 999px; + background: rgba(85, 166, 255, 0.08); + color: #b7d9ff; + font-size: 11px; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + .conversation-changes-card { display: grid; width: min(860px, 100%);