From 8dfe504f860bbb213c49d27a1d7a6844c092902f Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Sun, 26 Apr 2026 02:12:15 +0800 Subject: [PATCH] feat(desktop): add assistant message actions --- .../assistant-message-actions.md | 55 +++++ ...de-electron-desktop-implementation-plan.md | 95 +++++++++ packages/desktop/scripts/e2e-cdp-smoke.mjs | 166 +++++++++++++++ .../src/main/acp/createE2eAcpClient.ts | 2 +- packages/desktop/src/renderer/App.tsx | 28 ++- .../renderer/components/layout/ChatThread.tsx | 196 +++++++++++++++++- .../components/layout/WorkspacePage.test.tsx | 92 ++++++++ .../components/layout/WorkspacePage.tsx | 12 ++ packages/desktop/src/renderer/styles.css | 57 +++++ 9 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 .qwen/e2e-tests/electron-desktop/assistant-message-actions.md diff --git a/.qwen/e2e-tests/electron-desktop/assistant-message-actions.md b/.qwen/e2e-tests/electron-desktop/assistant-message-actions.md new file mode 100644 index 000000000..c463fdbb2 --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/assistant-message-actions.md @@ -0,0 +1,55 @@ +# Assistant Message Actions and File Reference Chips + +- 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-25T18-10-35-606Z/` + +## 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 assistant response that references `README.md:1`. +5. Assert the assistant message renders compact Copy, Retry last prompt, and + Open Changes actions plus a file-reference chip. +6. Click Copy and verify visible composer feedback. +7. Click Retry and verify the previous user prompt is restored into the + composer without sending a new request, then clear the retry draft. +8. Continue the existing changed-files, review, settings, terminal, and final + layout smoke path. + +## Assertions + +- The assistant action row is inside the chat timeline and stays above the + composer without overlap. +- The assistant action row exposes `Copy Response`, `Retry Last Prompt`, and + `Open Changes` as accessible button labels. +- The assistant file chip shows `README.md:1` and exposes `Open README.md:1`. +- Copy produces `Copied response.` feedback. +- Retry restores `Please exercise command approval.` to the composer and does + not auto-send a new approval request. +- The assistant message does not show fake tool call IDs or session IDs. +- Console errors: 0. +- Failed local network requests: 0. + +## Artifacts + +- `assistant-message-actions.json` +- `assistant-message-actions.png` +- `assistant-retry-draft.json` +- `resolved-tool-activity.json` +- `conversation-changes-summary.json` +- `completed-workspace.png` +- `electron.log` +- `summary.json` + +## Known Uncovered Risk + +The harness covers one deterministic assistant response with one file +reference. Live assistant prose with many repeated paths, uncommon file +extensions, or markdown-wrapped references still needs broader coverage. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 3b7b113df..74119e611 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,101 @@ execution order, verification, decisions, and remaining work. ## Codex Alignment Progress +### Completed Slice: Assistant Message Actions and File Reference Chips + +Status: completed in iteration 11. + +Goal: add compact assistant message actions and clickable file-reference chips +inside the conversation timeline. + +User-visible value: after an assistant response, users can copy the response, +reuse the last prompt, jump into changed-file review, and open referenced files +without leaving the workbench or reading protocol/debug output. + +Expected files: + +- `packages/desktop/src/renderer/App.tsx` +- `packages/desktop/src/renderer/components/layout/ChatThread.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` +- `packages/desktop/src/renderer/styles.css` +- `packages/desktop/src/main/acp/createE2eAcpClient.ts` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/assistant-message-actions.md` +- `design/qwen-code-electron-desktop-implementation-plan.md` + +Acceptance criteria: + +- Assistant messages render a compact action row with Copy, Retry last prompt, + and Open Changes when changed files exist. +- Retry last prompt is safe: it restores the previous user prompt into the + composer instead of auto-sending a new agent request. +- File references in assistant prose, such as `README.md:1`, render as compact + chips with an accessible open action. +- Copy and open actions use existing desktop-safe preload/browser APIs and do + not expose ACP/session IDs in the main timeline. +- The message card stays within the conversation column and does not overlap + the composer in real Electron. + +Verification: + +- Unit/component test command: + `cd packages/desktop && SHELL=/bin/bash npx vitest run 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 a prompt, approve the fake + command request, wait for the assistant response, assert the assistant action + row and file chip, copy the response, retry the last prompt into the + composer, clear the retry draft, then continue the existing review/settings/ + terminal smoke path. +- E2E assertions: assistant message action row is present, the file chip shows + `README.md:1`, Copy produces visible feedback, Retry restores the original + prompt without auto-sending, Open Changes remains contextual, assistant + geometry stays inside the timeline above the composer, and console errors/ + failed local requests are absent. +- Diagnostic artifacts: CDP screenshots, assistant action JSON, retry composer + JSON, Electron log, summary JSON under + `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for prototype-constrained compact + inline actions and file chip density; `electron-desktop-dev` for renderer + changes and real Electron CDP verification; `brainstorming` applied by + choosing the narrow conversation-first option from the repo plan and + immutable prototype without pausing the autonomous Ralph loop. + +Notes and decisions: + +- The prototype shows response actions and changed-file controls in the + reading flow, so the action row stays under assistant messages instead of + becoming a toolbar or drawer. +- Retry is intentionally non-destructive and does not auto-send; it drafts the + last user prompt in the composer so users can inspect or edit before sending. +- File reference chips reuse the existing project-relative open-file path and + remain bounded so long paths cannot stretch the timeline. + +Verification results: + +- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx` + passed with 9 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-25T18-10-35-606Z/`. + +Next work: + +- Continue rich conversation primitives by adding clearer assistant feedback + states for copy/retry failures and by supporting multiple dense assistant + messages at compact viewport widths. +- Add a follow-up fake ACP scenario with longer assistant prose and several + repeated file references to harden chip extraction, dedupe, and overflow. + ### Completed Slice: Rich Tool-Call Activity Cards Status: completed in iteration 10. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index d27ac7578..5203e0391 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -79,6 +79,13 @@ async function main() { await waitForText('E2E fake ACP response received'); await assertResolvedToolActivity('resolved-tool-activity.json'); await saveScreenshot('resolved-tool-activity.png'); + await assertAssistantMessageActions('assistant-message-actions.json'); + await saveScreenshot('assistant-message-actions.png'); + await clickButton('Copy Response'); + await waitForText('Copied response.'); + await clickButton('Retry Last Prompt'); + await assertRetryDrafted('assistant-retry-draft.json'); + await setFieldByAriaLabel('Message', ''); await assertConversationChangesSummary('conversation-changes-summary.json'); await waitForSelector('[data-testid="thread-list"]'); @@ -877,6 +884,165 @@ async function assertResolvedToolActivity(fileName) { } } +async function assertAssistantMessageActions(fileName) { + await waitForSelector('[data-testid="assistant-message-actions"]'); + const snapshot = await evaluate(`(() => { + const message = [...document.querySelectorAll('[data-testid="assistant-message"]')] + .find((candidate) => + candidate.innerText.includes('E2E fake ACP response received') + ); + const actions = message?.querySelector( + '[data-testid="assistant-message-actions"]' + ); + const fileReferences = message?.querySelector( + '[data-testid="assistant-file-references"]' + ); + 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, + messageText: message?.innerText ?? '', + actionLabels: actions + ? [...actions.querySelectorAll('button')].map( + (button) => button.getAttribute('aria-label') || '' + ) + : [], + fileReferenceText: fileReferences?.innerText ?? '', + fileReferenceLabels: fileReferences + ? [...fileReferences.querySelectorAll('button')].map( + (button) => button.getAttribute('aria-label') || '' + ) + : [], + messageRect: rectFor(message), + actionsRect: rectFor(actions), + timelineRect: rectFor(timeline), + composerRect: rectFor(composer) + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + for (const expectedLabel of [ + 'Copy Response', + 'Retry Last Prompt', + 'Open Changes', + ]) { + if (!snapshot.actionLabels.includes(expectedLabel)) { + throw new Error( + `Assistant action row missing ${expectedLabel}: ${snapshot.actionLabels.join( + ', ', + )}`, + ); + } + } + + if (!snapshot.fileReferenceText.includes('README.md:1')) { + throw new Error( + `Assistant file chips missing README.md:1: ${snapshot.fileReferenceText}`, + ); + } + + if (!snapshot.fileReferenceLabels.includes('Open README.md:1')) { + throw new Error( + `Assistant file chip is not accessible: ${snapshot.fileReferenceLabels.join( + ', ', + )}`, + ); + } + + for (const internalText of ['e2e-terminal-check', 'session-e2e']) { + if (snapshot.messageText.includes(internalText)) { + throw new Error( + `Assistant message leaked internal text ${internalText}: ${snapshot.messageText}`, + ); + } + } + + if ( + !snapshot.messageRect || + !snapshot.actionsRect || + !snapshot.timelineRect || + !snapshot.composerRect + ) { + throw new Error( + `Assistant action geometry is missing: ${JSON.stringify(snapshot)}`, + ); + } + + if (snapshot.actionsRect.height > 40) { + throw new Error( + `Assistant action row is too tall: ${JSON.stringify( + snapshot.actionsRect, + )}`, + ); + } + + if ( + snapshot.messageRect.left < snapshot.timelineRect.left || + snapshot.messageRect.right > snapshot.timelineRect.right + 1 + ) { + throw new Error('Assistant message should stay inside the timeline.'); + } + + if (snapshot.messageRect.bottom > snapshot.composerRect.top) { + throw new Error('Assistant message overlaps the composer.'); + } +} + +async function assertRetryDrafted(fileName) { + const snapshot = await evaluate(`(() => { + const messageField = document.querySelector('[aria-label="Message"]'); + return { + composerValue: messageField?.value ?? '', + bodyText: document.body.innerText, + approvalCards: document.querySelectorAll( + '[data-testid="conversation-approval-card"]' + ).length, + assistantMessages: document.querySelectorAll( + '[data-testid="assistant-message"]' + ).length + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.composerValue !== 'Please exercise command approval.') { + throw new Error( + `Retry should restore the last prompt into the composer: ${snapshot.composerValue}`, + ); + } + + if (!snapshot.bodyText.includes('Restored last prompt to composer.')) { + throw new Error('Retry should provide visible composer feedback.'); + } + + if (snapshot.approvalCards !== 0) { + throw new Error('Retry should not auto-send a new approval request.'); + } +} + 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 76f331044..255757fec 100644 --- a/packages/desktop/src/main/acp/createE2eAcpClient.ts +++ b/packages/desktop/src/main/acp/createE2eAcpClient.ts @@ -179,7 +179,7 @@ export class E2eAcpClient implements AcpSessionClient { sessionUpdate: 'agent_message_chunk', content: { type: 'text', - text: `E2E fake ACP response received: ${prompt}`, + text: `E2E fake ACP response received: ${prompt}\n\nUpdated README.md:1 for review.`, }, }); diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index c8526efd8..34d4dfe17 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -89,6 +89,7 @@ export function App() { const [terminal, setTerminal] = useState(null); const [terminalError, setTerminalError] = useState(null); const [terminalNotice, setTerminalNotice] = useState(null); + const [chatNotice, setChatNotice] = useState(null); const [messageText, setMessageText] = useState(''); const [chatState, dispatchChat] = useReducer( chatReducer, @@ -604,6 +605,25 @@ export function App() { [activeProject], ); + const updateMessageText = useCallback((message: string) => { + setMessageText(message); + setChatNotice(null); + }, []); + + const copyChatMessage = useCallback(async (message: string) => { + try { + await writeClipboardText(message); + setChatNotice('Copied response.'); + } catch (error) { + setSessionError(getErrorMessage(error)); + } + }, []); + + const retryChatMessage = useCallback((message: string) => { + setMessageText(message); + setChatNotice('Restored last prompt to composer.'); + }, []); + const commitChanges = useCallback(async () => { if ( loadState.state !== 'ready' || @@ -846,6 +866,7 @@ export function App() { dispatchChat({ type: 'append_user_message', content }); setMessageText(''); + setChatNotice(null); void createDesktopSession( loadState.status.serverInfo, activeProject.path, @@ -893,6 +914,7 @@ export function App() { dispatchChat({ type: 'append_user_message', content }); socketRef.current.sendUserMessage(content); setMessageText(''); + setChatNotice(null); }, [activeProject, activeSessionId, loadState, messageText], ); @@ -952,18 +974,21 @@ export function App() { terminalError={terminalError} terminalInput={terminalInput} terminalNotice={terminalNotice} + chatNotice={chatNotice} onAskUserQuestionResponse={respondToAskUserQuestion} onAuthenticate={authenticate} onChooseWorkspace={chooseWorkspace} onClearTerminal={clearTerminal} onCommit={commitChanges} onCommitMessageChange={setCommitMessage} + onCopyMessage={copyChatMessage} onCopyTerminalOutput={copyTerminalOutput} onCreateSession={createSession} onKillTerminal={killTerminal} - onMessageTextChange={setMessageText} + onMessageTextChange={updateMessageText} onModeChange={changeMode} onModelChange={changeModel} + onOpenFileReference={openReviewFile} onPermissionResponse={respondToPermission} onRefreshProjectGitStatus={refreshProjectGitStatus} onOpenReviewFile={openReviewFile} @@ -977,6 +1002,7 @@ export function App() { onSettingsDispatch={dispatchSettings} onStageReviewTarget={stageReviewTarget} onStopGeneration={stopGeneration} + onRetryMessage={retryChatMessage} onTerminalCommandChange={setTerminalCommand} onTerminalInputChange={setTerminalInput} onWriteTerminalInput={writeTerminalInput} diff --git a/packages/desktop/src/renderer/components/layout/ChatThread.tsx b/packages/desktop/src/renderer/components/layout/ChatThread.tsx index cccd881d0..90214e1ec 100644 --- a/packages/desktop/src/renderer/components/layout/ChatThread.tsx +++ b/packages/desktop/src/renderer/components/layout/ChatThread.tsx @@ -17,6 +17,7 @@ import type { DesktopAskUserQuestionRequest, DesktopPermissionRequest, } from '../../../shared/desktopProtocol.js'; +import { CopyIcon, DiffIcon, RefreshIcon } from './SidebarIcons.js'; export function ChatThread({ activeProject, @@ -26,12 +27,16 @@ export function ChatThread({ isDraftSession, messageText, modelState, + notice, onAskUserQuestionResponse, + onCopyMessage, onModeChange, onModelChange, onMessageTextChange, + onOpenFileReference, onOpenReview, onPermissionResponse, + onRetryMessage, onSendMessage, onStopGeneration, }: { @@ -42,12 +47,16 @@ export function ChatThread({ isDraftSession: boolean; messageText: string; modelState: ModelState; + notice: string | null; onAskUserQuestionResponse: (requestId: string, optionId: string) => void; + onCopyMessage: (message: string) => void; onModeChange: (mode: DesktopApprovalMode) => void; onModelChange: (modelId: string) => void; onMessageTextChange: (message: string) => void; + onOpenFileReference: (filePath: string) => void; onOpenReview: () => void; onPermissionResponse: (requestId: string, optionId: string) => void; + onRetryMessage: (message: string) => void; onSendMessage: (event: FormEvent) => void; onStopGeneration: () => void; }) { @@ -81,8 +90,11 @@ export function ChatThread({ gitDiff={gitDiff} isDraftSession={isDraftSession} onAskUserQuestionResponse={onAskUserQuestionResponse} + onCopyMessage={onCopyMessage} + onOpenFileReference={onOpenFileReference} onOpenReview={onOpenReview} onPermissionResponse={onPermissionResponse} + onRetryMessage={onRetryMessage} />
+ {notice ? ( + {notice} + ) : null} {disabledReason ? ( {disabledReason} ) : null} @@ -186,8 +201,11 @@ function ChatTimeline({ gitDiff, isDraftSession, onAskUserQuestionResponse, + onCopyMessage, + onOpenFileReference, onOpenReview, onPermissionResponse, + onRetryMessage, state, }: { activeProject: DesktopProject | null; @@ -195,8 +213,11 @@ function ChatTimeline({ gitDiff: DesktopGitDiff | null; isDraftSession: boolean; onAskUserQuestionResponse: (requestId: string, optionId: string) => void; + onCopyMessage: (message: string) => void; + onOpenFileReference: (filePath: string) => void; onOpenReview: () => void; onPermissionResponse: (requestId: string, optionId: string) => void; + onRetryMessage: (message: string) => void; state: ChatState; }) { const timelineRef = useRef(null); @@ -249,11 +270,29 @@ function ChatTimeline({ ); } + let latestUserMessage: string | null = null; + return (
- {state.items.map((item) => ( - - ))} + {state.items.map((item) => { + const previousUserMessage = latestUserMessage; + if (item.type === 'message' && item.role === 'user') { + latestUserMessage = item.text; + } + + return ( + + ); + })} void; + onOpenFileReference: (filePath: string) => void; + onOpenReview: () => void; + onRetryMessage: (message: string) => void; + previousUserMessage: string | null; +}) { if (item.type === 'message') { + const fileReferences = + item.role === 'assistant' ? extractFileReferences(item.text) : []; + const hasChangedFiles = Boolean(gitDiff?.files.length); + return ( -
+
{item.role}

{item.text}

+ {fileReferences.length > 0 ? ( +
    + {fileReferences.map((reference) => ( +
  • + +
  • + ))} +
+ ) : null} + {item.role === 'assistant' ? ( + + ) : null}
); } @@ -316,6 +410,98 @@ function TimelineItem({ item }: { item: ChatTimelineItem }) { return
{item.label}
; } +function AssistantMessageActions({ + hasChangedFiles, + message, + onCopyMessage, + onOpenReview, + onRetryMessage, + retryMessage, +}: { + hasChangedFiles: boolean; + message: string; + onCopyMessage: (message: string) => void; + onOpenReview: () => void; + onRetryMessage: (message: string) => void; + retryMessage: string | null; +}) { + return ( +
+ + + {hasChangedFiles ? ( + + ) : null} +
+ ); +} + +const FILE_REFERENCE_PATTERN = + /(?:^|[\s([{"'`])((?:[\w@.-]+\/)*[\w@.-]+\.(?:bash|c|cc|cjs|cpp|css|go|h|hpp|html|java|js|jsx|json|kt|lock|md|mjs|py|rs|scss|sh|sql|swift|toml|ts|tsx|txt|xml|ya?ml|zsh)(?::\d+)?)(?=$|[\s)\]},.;:"'`])/giu; + +function extractFileReferences( + text: string, +): Array<{ label: string; path: string }> { + const references: Array<{ label: string; path: string }> = []; + const seen = new Set(); + + for (const match of text.matchAll(FILE_REFERENCE_PATTERN)) { + const label = match[1]; + if (!label || seen.has(label)) { + continue; + } + + seen.add(label); + references.push({ + label, + path: stripFileReferenceLine(label), + }); + } + + return references.slice(0, 6); +} + +function stripFileReferenceLine(reference: string): string { + const lineMatch = /^(.*):\d+$/u.exec(reference); + return lineMatch?.[1] ?? reference; +} + function ToolActivityCard({ toolCall, }: { diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx index 80d7f3fc3..53c3691f0 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx @@ -394,6 +394,94 @@ describe('WorkspacePage', () => { expect(renderedContainer.querySelector('.chat-tool')).toBeNull(); }); + it('renders assistant message actions and clickable file reference chips', () => { + const onCopyMessage = vi.fn(); + const onOpenReviewFile = vi.fn(); + const onRetryMessage = vi.fn(); + let chatState = chatReducer(createInitialChatState(), { + type: 'append_user_message', + content: 'Summarize the project changes', + }); + chatState = chatReducer(chatState, { + type: 'server_message', + message: { + type: 'message_delta', + role: 'assistant', + text: 'Updated README.md:1 and packages/desktop/src/renderer/App.tsx:12.', + }, + }); + chatState = chatReducer(chatState, { + type: 'server_message', + message: { type: 'message_complete' }, + }); + + const renderedContainer = renderWorkspace({ + chatState, + onCopyMessage, + onOpenFileReference: onOpenReviewFile, + onOpenReviewFile, + onRetryMessage, + }); + const assistantMessage = renderedContainer.querySelector( + '[data-testid="assistant-message"]', + ); + const actionRow = renderedContainer.querySelector( + '[data-testid="assistant-message-actions"]', + ); + const fileReferences = renderedContainer.querySelector( + '[data-testid="assistant-file-references"]', + ); + + expect(assistantMessage?.textContent).toContain('README.md:1'); + expect(actionRow).toBeTruthy(); + expect(fileReferences?.textContent).toContain('README.md:1'); + expect(fileReferences?.textContent).toContain( + 'packages/desktop/src/renderer/App.tsx:12', + ); + + act(() => { + ( + actionRow?.querySelector( + 'button[aria-label="Copy Response"]', + ) as HTMLButtonElement + ).click(); + }); + expect(onCopyMessage).toHaveBeenCalledWith( + 'Updated README.md:1 and packages/desktop/src/renderer/App.tsx:12.', + ); + + act(() => { + ( + actionRow?.querySelector( + 'button[aria-label="Retry Last Prompt"]', + ) as HTMLButtonElement + ).click(); + }); + expect(onRetryMessage).toHaveBeenCalledWith( + 'Summarize the project changes', + ); + + act(() => { + ( + fileReferences?.querySelector( + 'button[aria-label="Open README.md:1"]', + ) as HTMLButtonElement + ).click(); + }); + expect(onOpenReviewFile).toHaveBeenCalledWith('README.md'); + + act(() => { + ( + actionRow?.querySelector( + 'button[aria-label="Open Changes"]', + ) as HTMLButtonElement + ).click(); + }); + expect( + renderedContainer.querySelector('[data-testid="review-panel"]'), + ).toBeTruthy(); + }); + it('routes terminal output through an attach action', () => { const onAttachTerminalOutput = vi.fn(); const renderedContainer = renderWorkspace({ @@ -489,18 +577,21 @@ function renderWorkspace( terminalError: null, terminalInput: '', terminalNotice: null, + chatNotice: null, onAskUserQuestionResponse: vi.fn(), onAuthenticate: vi.fn(), onChooseWorkspace: vi.fn(), onClearTerminal: vi.fn(), onCommit: vi.fn(), onCommitMessageChange: vi.fn(), + onCopyMessage: vi.fn(), onCopyTerminalOutput: vi.fn(), onCreateSession: vi.fn(), onKillTerminal: vi.fn(), onMessageTextChange: vi.fn(), onModeChange: vi.fn(), onModelChange: vi.fn(), + onOpenFileReference: vi.fn(), onPermissionResponse: vi.fn(), onRefreshProjectGitStatus: vi.fn(), onOpenReviewFile: vi.fn(), @@ -514,6 +605,7 @@ function renderWorkspace( onSettingsDispatch: vi.fn(), onStageReviewTarget: vi.fn(), onStopGeneration: vi.fn(), + onRetryMessage: vi.fn(), onTerminalCommandChange: vi.fn(), onTerminalInputChange: vi.fn(), onWriteTerminalInput: vi.fn(), diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx index ea4bbb406..22e0ece15 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx @@ -57,12 +57,14 @@ export function WorkspacePage({ onClearTerminal, onCommit, onCommitMessageChange, + onCopyMessage, onCopyTerminalOutput, onCreateSession, onKillTerminal, onMessageTextChange, onModeChange, onModelChange, + onOpenFileReference, onPermissionResponse, onRefreshProjectGitStatus, onOpenReviewFile, @@ -76,9 +78,11 @@ export function WorkspacePage({ onSettingsDispatch, onStageReviewTarget, onStopGeneration, + onRetryMessage, onTerminalCommandChange, onTerminalInputChange, onWriteTerminalInput, + chatNotice, }: { activeProject: DesktopProject | null; activeProjectId: string | null; @@ -101,18 +105,21 @@ export function WorkspacePage({ terminalError: string | null; terminalInput: string; terminalNotice: string | null; + chatNotice: string | null; onAskUserQuestionResponse: (requestId: string, optionId: string) => void; onAuthenticate: (methodId: string) => void; onChooseWorkspace: () => void; onClearTerminal: () => void; onCommit: () => void; onCommitMessageChange: (message: string) => void; + onCopyMessage: (message: string) => void; onCopyTerminalOutput: () => void; onCreateSession: () => void; onKillTerminal: () => void; onMessageTextChange: (message: string) => void; onModeChange: (mode: DesktopApprovalMode) => void; onModelChange: (modelId: string) => void; + onOpenFileReference: (filePath: string) => void; onPermissionResponse: (requestId: string, optionId: string) => void; onRefreshProjectGitStatus: () => void; onOpenReviewFile: (filePath: string) => void; @@ -126,6 +133,7 @@ export function WorkspacePage({ onSettingsDispatch: Dispatch; onStageReviewTarget: (target: DesktopGitReviewTarget) => void; onStopGeneration: () => void; + onRetryMessage: (message: string) => void; onTerminalCommandChange: (command: string) => void; onTerminalInputChange: (input: string) => void; onWriteTerminalInput: () => void; @@ -207,12 +215,16 @@ export function WorkspacePage({ isDraftSession={isDraftSession} messageText={messageText} modelState={modelState} + notice={chatNotice} onAskUserQuestionResponse={onAskUserQuestionResponse} + onCopyMessage={onCopyMessage} onModeChange={onModeChange} onModelChange={onModelChange} onMessageTextChange={onMessageTextChange} + onOpenFileReference={onOpenFileReference} onOpenReview={showReview} onPermissionResponse={onPermissionResponse} + onRetryMessage={onRetryMessage} onSendMessage={onSendMessage} onStopGeneration={onStopGeneration} /> diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 108040e82..487de7660 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -722,6 +722,63 @@ summary:focus-visible { white-space: pre-wrap; } +.message-file-references { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.message-file-references button, +.message-action-button { + border: 1px solid var(--line); + background: rgba(238, 244, 239, 0.035); + color: var(--text-soft); +} + +.message-file-references button { + max-width: 280px; + min-height: 24px; + overflow: hidden; + padding: 3px 8px; + border-radius: 999px; + color: #b7d9ff; + font-size: 11px; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-file-references button:not(:disabled):hover, +.message-action-button:not(:disabled):hover { + border-color: rgba(85, 166, 255, 0.32); + background: var(--accent-soft); + color: #d6eaff; +} + +.message-action-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; +} + +.message-action-button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 7px; +} + +.message-action-button svg { + width: 16px; + height: 16px; +} + .chat-plan { align-self: center; }