mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 05:31:02 +00:00
feat(desktop): attach terminal output to composer
This commit is contained in:
parent
3f007a350a
commit
f640c4ea9d
9 changed files with 293 additions and 38 deletions
|
|
@ -15,8 +15,9 @@ Slice 13 basic scoped terminal.
|
||||||
5. Copy the terminal transcript and verify the UI reports copy success.
|
5. Copy the terminal transcript and verify the UI reports copy success.
|
||||||
6. Start a command that waits for stdin, send input through the drawer, and
|
6. Start a command that waits for stdin, send input through the drawer, and
|
||||||
verify the command output includes that stdin.
|
verify the command output includes that stdin.
|
||||||
7. Send the terminal output to the active AI thread and approve the fake ACP
|
7. Attach the terminal output to the composer, verify no AI turn starts until
|
||||||
command request.
|
the composer Send action is clicked, then approve the fake ACP command
|
||||||
|
request.
|
||||||
8. Start a long-running command and click Kill.
|
8. Start a long-running command and click Kill.
|
||||||
9. Click Clear and verify the drawer output resets.
|
9. Click Clear and verify the drawer output resets.
|
||||||
|
|
||||||
|
|
@ -32,8 +33,9 @@ Slice 13 basic scoped terminal.
|
||||||
are visible in the bottom drawer and do not use Node integration.
|
are visible in the bottom drawer and do not use Node integration.
|
||||||
- Copy output uses the preload-whitelisted Electron clipboard IPC, not renderer
|
- Copy output uses the preload-whitelisted Electron clipboard IPC, not renderer
|
||||||
Node integration or an unbounded IPC channel.
|
Node integration or an unbounded IPC channel.
|
||||||
- Send to AI uses the existing authenticated WebSocket user-message path with
|
- Attach Output appends a bounded terminal transcript to the composer draft,
|
||||||
a bounded terminal transcript.
|
does not touch the session WebSocket immediately, and requires the normal
|
||||||
|
composer Send action before a new agent turn starts.
|
||||||
|
|
||||||
## Diagnostics on Failure
|
## Diagnostics on Failure
|
||||||
|
|
||||||
|
|
@ -97,6 +99,34 @@ Additional artifacts collected:
|
||||||
- `completed-layout.json`
|
- `completed-layout.json`
|
||||||
- `completed-workspace.png`
|
- `completed-workspace.png`
|
||||||
|
|
||||||
|
## Automated Coverage Added In Codex Alignment Iteration 5
|
||||||
|
|
||||||
|
Iteration 5 changes terminal follow-up from direct AI send to explicit
|
||||||
|
attach-to-composer and updates the real Electron CDP smoke to cover the safer
|
||||||
|
workflow:
|
||||||
|
|
||||||
|
1. Launch real Electron with isolated HOME/runtime/user-data and fake ACP.
|
||||||
|
2. Open the fake Git project, create a thread from the project composer, and
|
||||||
|
complete the existing approval/review/commit path.
|
||||||
|
3. Expand Terminal, run stdout and stdin commands, then click `Attach Output`.
|
||||||
|
4. Assert the composer contains the bounded terminal transcript, the terminal
|
||||||
|
action is labeled `Attach Output`, the legacy `Send to AI` action is absent,
|
||||||
|
and no new `Approve Once` request appears before composer Send.
|
||||||
|
5. Click composer `Send`, approve the fake ACP request, and verify the fake ACP
|
||||||
|
response includes the terminal-output prompt.
|
||||||
|
|
||||||
|
Executable harness:
|
||||||
|
|
||||||
|
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
|
||||||
|
|
||||||
|
Additional artifacts collected:
|
||||||
|
|
||||||
|
- `terminal-attachment.json`
|
||||||
|
- `terminal-expanded-layout.json`
|
||||||
|
- `terminal-expanded.png`
|
||||||
|
- `completed-layout.json`
|
||||||
|
- `completed-workspace.png`
|
||||||
|
|
||||||
## Execution Results
|
## Execution Results
|
||||||
|
|
||||||
Codex alignment iteration 4:
|
Codex alignment iteration 4:
|
||||||
|
|
@ -110,6 +140,17 @@ Codex alignment iteration 4:
|
||||||
- Success artifacts:
|
- Success artifacts:
|
||||||
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-00-08-461Z/`.
|
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-00-08-461Z/`.
|
||||||
|
|
||||||
|
Codex alignment iteration 5:
|
||||||
|
|
||||||
|
- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
|
||||||
|
passed: 5 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.
|
||||||
|
- Success artifacts:
|
||||||
|
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-08-17-022Z/`.
|
||||||
|
|
||||||
Slice 16:
|
Slice 16:
|
||||||
|
|
||||||
- `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests.
|
- `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests.
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,101 @@ execution order, verification, decisions, and remaining work.
|
||||||
|
|
||||||
## Codex Alignment Progress
|
## Codex Alignment Progress
|
||||||
|
|
||||||
### Active Slice: Collapsed Terminal Status Strip Alignment
|
### Completed Slice: Terminal Attach-to-Composer Workflow
|
||||||
|
|
||||||
|
Status: completed in iteration 5.
|
||||||
|
|
||||||
|
Goal: change terminal output follow-up from an immediate `Send to AI` action
|
||||||
|
into an explicit attach-to-composer flow, so users can review and edit command
|
||||||
|
output before deciding whether to send it to the agent.
|
||||||
|
|
||||||
|
User-visible value: terminal output becomes contextual material in the task
|
||||||
|
composer rather than a hidden second send path that can unexpectedly trigger a
|
||||||
|
new agent turn. This keeps the conversation-first workbench aligned with
|
||||||
|
`home.jpg` while preserving the terminal as a supporting tool.
|
||||||
|
|
||||||
|
Expected files:
|
||||||
|
|
||||||
|
- `packages/desktop/src/renderer/App.tsx`
|
||||||
|
- `packages/desktop/src/renderer/api/websocket.ts`
|
||||||
|
- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx`
|
||||||
|
- `packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx`
|
||||||
|
- `packages/desktop/src/renderer/components/layout/SidebarIcons.tsx`
|
||||||
|
- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx`
|
||||||
|
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
|
||||||
|
- `.qwen/e2e-tests/electron-desktop/terminal-drawer.md`
|
||||||
|
- `design/qwen-code-electron-desktop-implementation-plan.md`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The expanded Terminal action is labeled as attaching output to the composer,
|
||||||
|
not sending directly to AI.
|
||||||
|
- Attaching terminal output appends a bounded terminal transcript to the
|
||||||
|
existing composer text and shows a clear success notice.
|
||||||
|
- The attach action works whenever terminal output exists, including before a
|
||||||
|
thread is selected, and does not require or write to the session WebSocket.
|
||||||
|
- The user must still click Send from the composer before a new agent turn is
|
||||||
|
created.
|
||||||
|
- Copy, clear, kill, run command, stdin, expand, and collapse behavior is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
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, create a composer-first thread,
|
||||||
|
approve the fake command, review and commit changes, expand Terminal, run
|
||||||
|
stdout and stdin commands, attach the resulting output to the composer,
|
||||||
|
assert no fake ACP follow-up happens until Send is clicked, then send the
|
||||||
|
composer text and approve the fake command request.
|
||||||
|
- E2E assertions: attach button is present and `Send to AI` is absent; composer
|
||||||
|
contains the terminal transcript after attach; terminal notice confirms the
|
||||||
|
attachment; the output stays editable in the composer; console errors and
|
||||||
|
failed local requests are absent.
|
||||||
|
- Diagnostic artifacts: CDP screenshots, terminal layout JSON, composer attach
|
||||||
|
JSON, Electron log, summary JSON under
|
||||||
|
`.qwen/e2e-tests/electron-desktop/artifacts/`.
|
||||||
|
- Required skills applied: `frontend-design` for prototype-constrained terminal
|
||||||
|
action wording and compact composer-centric hierarchy; `electron-desktop-dev`
|
||||||
|
for renderer changes and real Electron CDP verification.
|
||||||
|
|
||||||
|
Notes and decisions:
|
||||||
|
|
||||||
|
- The prototype keeps the composer as the task control center, so terminal
|
||||||
|
output should land there for user review rather than bypassing it.
|
||||||
|
- This slice intentionally preserves the transcript formatting and bounding
|
||||||
|
logic from the existing send path, but changes the destination from WebSocket
|
||||||
|
send to composer draft text.
|
||||||
|
- The WebSocket helper no longer needs a separate terminal-output send method
|
||||||
|
because the final send is the same explicit user-message path as any other
|
||||||
|
composer submit.
|
||||||
|
|
||||||
|
Verification results:
|
||||||
|
|
||||||
|
- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
|
||||||
|
passed with 5 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-08-17-022Z/`.
|
||||||
|
|
||||||
|
Next work:
|
||||||
|
|
||||||
|
- Improve review safety by replacing `Accept`/`Revert` terminology with
|
||||||
|
Stage/Unstage/Discard and confirming destructive discard paths.
|
||||||
|
- Continue prototype fidelity work in the conversation timeline by hiding
|
||||||
|
protocol/session noise and adding inline changed-file summaries.
|
||||||
|
|
||||||
|
### Completed Slice: Collapsed Terminal Status Strip Alignment
|
||||||
|
|
||||||
Status: completed in iteration 4.
|
Status: completed in iteration 4.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,8 +136,10 @@ async function main() {
|
||||||
await clickButton('Send Input');
|
await clickButton('Send Input');
|
||||||
await waitForText('Input sent.');
|
await waitForText('Input sent.');
|
||||||
await waitForText('stdin:desktop-e2e-stdin');
|
await waitForText('stdin:desktop-e2e-stdin');
|
||||||
await clickButton('Send to AI');
|
await clickButton('Attach Output');
|
||||||
await waitForText('Sent terminal output to AI.');
|
await waitForText('Attached terminal output to composer.');
|
||||||
|
await assertTerminalOutputAttached('terminal-attachment.json');
|
||||||
|
await clickButton('Send');
|
||||||
await waitForText('Approve Once');
|
await waitForText('Approve Once');
|
||||||
await clickButton('Approve Once');
|
await clickButton('Approve Once');
|
||||||
await waitForText(
|
await waitForText(
|
||||||
|
|
@ -747,6 +749,59 @@ async function assertTerminalExpandedLayout(fileName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertTerminalOutputAttached(fileName) {
|
||||||
|
const snapshot = await evaluate(`(() => {
|
||||||
|
const textarea = document.querySelector('textarea[aria-label="Message"]');
|
||||||
|
const terminalActions = document.querySelector('.terminal-actions');
|
||||||
|
const text = textarea?.value ?? '';
|
||||||
|
return {
|
||||||
|
composerValue: text,
|
||||||
|
hasTerminalPrompt: text.includes('Review this terminal output'),
|
||||||
|
hasCommand: text.includes('$ node -e'),
|
||||||
|
hasStdinOutput: text.includes('stdin:desktop-e2e-stdin'),
|
||||||
|
hasAttachAction: Boolean(
|
||||||
|
document.querySelector('button[aria-label="Attach Output"]')
|
||||||
|
),
|
||||||
|
hasLegacySendAction: Boolean(
|
||||||
|
document.querySelector('button[aria-label="Send to AI"]')
|
||||||
|
) || (terminalActions?.textContent ?? '').includes('Send to AI'),
|
||||||
|
hasPendingApproval: document.body.innerText.includes('Approve Once')
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(artifactDir, fileName),
|
||||||
|
`${JSON.stringify(snapshot, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snapshot.hasAttachAction) {
|
||||||
|
throw new Error('Terminal attach action was not rendered.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasLegacySendAction) {
|
||||||
|
throw new Error('Terminal should attach output, not show Send to AI.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!snapshot.hasTerminalPrompt ||
|
||||||
|
!snapshot.hasCommand ||
|
||||||
|
!snapshot.hasStdinOutput
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Terminal output was not attached to composer: ${JSON.stringify(
|
||||||
|
snapshot,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasPendingApproval) {
|
||||||
|
throw new Error(
|
||||||
|
'Attaching terminal output should not create an agent approval request.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function assertSettingsPageLayout(fileName) {
|
async function assertSettingsPageLayout(fileName) {
|
||||||
const metrics = await evaluate(`(() => {
|
const metrics = await evaluate(`(() => {
|
||||||
const rectFor = (selector) => {
|
const rectFor = (selector) => {
|
||||||
|
|
|
||||||
|
|
@ -713,14 +713,9 @@ export function App() {
|
||||||
}
|
}
|
||||||
}, [terminal]);
|
}, [terminal]);
|
||||||
|
|
||||||
const sendTerminalOutputToAi = useCallback(() => {
|
const attachTerminalOutputToComposer = useCallback(() => {
|
||||||
if (
|
if (!terminal) {
|
||||||
!activeSessionId ||
|
setTerminalError('Run a terminal command before attaching output.');
|
||||||
!socketRef.current ||
|
|
||||||
chatState.connection !== 'connected' ||
|
|
||||||
!terminal
|
|
||||||
) {
|
|
||||||
setTerminalError('Open a thread before sending terminal output to AI.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -730,12 +725,15 @@ export function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = buildTerminalOutputPrompt(terminal);
|
const content = buildTerminalAttachmentDraft(terminal);
|
||||||
dispatchChat({ type: 'append_user_message', content });
|
setMessageText((current) =>
|
||||||
socketRef.current.sendTerminalOutput(content);
|
current.trim().length > 0
|
||||||
|
? `${current.trimEnd()}\n\n${content}`
|
||||||
|
: content,
|
||||||
|
);
|
||||||
setTerminalError(null);
|
setTerminalError(null);
|
||||||
setTerminalNotice('Sent terminal output to AI.');
|
setTerminalNotice('Attached terminal output to composer.');
|
||||||
}, [activeSessionId, chatState.connection, terminal]);
|
}, [terminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState.state !== 'ready' || terminal?.status !== 'running') {
|
if (loadState.state !== 'ready' || terminal?.status !== 'running') {
|
||||||
|
|
@ -972,7 +970,7 @@ export function App() {
|
||||||
onRevertReviewTarget={revertReviewTarget}
|
onRevertReviewTarget={revertReviewTarget}
|
||||||
onRunTerminalCommand={runTerminalCommand}
|
onRunTerminalCommand={runTerminalCommand}
|
||||||
onSaveSettings={saveSettings}
|
onSaveSettings={saveSettings}
|
||||||
onSendTerminalOutputToAi={sendTerminalOutputToAi}
|
onAttachTerminalOutput={attachTerminalOutputToComposer}
|
||||||
onSelectProject={selectProject}
|
onSelectProject={selectProject}
|
||||||
onSelectSession={selectSession}
|
onSelectSession={selectSession}
|
||||||
onSendMessage={sendMessage}
|
onSendMessage={sendMessage}
|
||||||
|
|
@ -1041,7 +1039,7 @@ function formatTerminalTranscript(terminal: DesktopTerminal): string {
|
||||||
return `$ ${terminal.command}\n[${terminal.status}]${exitText}\n${terminal.output}`;
|
return `$ ${terminal.command}\n[${terminal.status}]${exitText}\n${terminal.output}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTerminalOutputPrompt(terminal: DesktopTerminal): string {
|
function buildTerminalAttachmentDraft(terminal: DesktopTerminal): string {
|
||||||
const transcript = formatTerminalTranscript(terminal);
|
const transcript = formatTerminalTranscript(terminal);
|
||||||
const boundedTranscript =
|
const boundedTranscript =
|
||||||
transcript.length > 12_000
|
transcript.length > 12_000
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export interface SessionSocketHandlers {
|
||||||
|
|
||||||
export interface SessionSocketClient {
|
export interface SessionSocketClient {
|
||||||
sendUserMessage(content: string): void;
|
sendUserMessage(content: string): void;
|
||||||
sendTerminalOutput(content: string): void;
|
|
||||||
respondToPermission(requestId: string, optionId: string): void;
|
respondToPermission(requestId: string, optionId: string): void;
|
||||||
respondToAskUserQuestion(
|
respondToAskUserQuestion(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
|
|
@ -55,9 +54,6 @@ export function connectSessionSocket(
|
||||||
sendUserMessage(content: string): void {
|
sendUserMessage(content: string): void {
|
||||||
sendClientMessage(socket, { type: 'user_message', content });
|
sendClientMessage(socket, { type: 'user_message', content });
|
||||||
},
|
},
|
||||||
sendTerminalOutput(content: string): void {
|
|
||||||
sendClientMessage(socket, { type: 'user_message', content });
|
|
||||||
},
|
|
||||||
respondToPermission(requestId: string, optionId: string): void {
|
respondToPermission(requestId: string, optionId: string): void {
|
||||||
sendClientMessage(socket, {
|
sendClientMessage(socket, {
|
||||||
type: 'permission_response',
|
type: 'permission_response',
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,33 @@ export function CopyIcon(props: SidebarIconProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PaperclipIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m8.1 12.1 6.2-6.2c1.4-1.4 3.6-1.4 5 0s1.4 3.6 0 5l-8.1 8.1c-1.9 1.9-5 1.9-6.9 0s-1.9-5 0-6.9l7.8-7.8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m8.3 15.8 7.3-7.3"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendIcon(props: SidebarIconProps) {
|
export function SendIcon(props: SidebarIconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
SendIcon,
|
PaperclipIcon,
|
||||||
StopIcon,
|
StopIcon,
|
||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
|
@ -21,13 +21,13 @@ export function TerminalDrawer({
|
||||||
isExpanded,
|
isExpanded,
|
||||||
input,
|
input,
|
||||||
notice,
|
notice,
|
||||||
|
onAttachOutput,
|
||||||
onClear,
|
onClear,
|
||||||
onCommandChange,
|
onCommandChange,
|
||||||
onCopyOutput,
|
onCopyOutput,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onKill,
|
onKill,
|
||||||
onRun,
|
onRun,
|
||||||
onSendOutputToAi,
|
|
||||||
onToggleExpanded,
|
onToggleExpanded,
|
||||||
onWriteInput,
|
onWriteInput,
|
||||||
project,
|
project,
|
||||||
|
|
@ -38,13 +38,13 @@ export function TerminalDrawer({
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
input: string;
|
input: string;
|
||||||
notice: string | null;
|
notice: string | null;
|
||||||
|
onAttachOutput: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onCommandChange: (command: string) => void;
|
onCommandChange: (command: string) => void;
|
||||||
onCopyOutput: () => void;
|
onCopyOutput: () => void;
|
||||||
onInputChange: (input: string) => void;
|
onInputChange: (input: string) => void;
|
||||||
onKill: () => void;
|
onKill: () => void;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
onSendOutputToAi: () => void;
|
|
||||||
onToggleExpanded: () => void;
|
onToggleExpanded: () => void;
|
||||||
onWriteInput: () => void;
|
onWriteInput: () => void;
|
||||||
project: DesktopProject | null;
|
project: DesktopProject | null;
|
||||||
|
|
@ -110,15 +110,15 @@ export function TerminalDrawer({
|
||||||
<span className="sr-only">Copy Output</span>
|
<span className="sr-only">Copy Output</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Send to AI"
|
aria-label="Attach Output"
|
||||||
className="terminal-icon-button"
|
className="terminal-icon-button"
|
||||||
disabled={!hasOutput}
|
disabled={!hasOutput}
|
||||||
title="Send to AI"
|
title="Attach Output to Composer"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSendOutputToAi}
|
onClick={onAttachOutput}
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<PaperclipIcon />
|
||||||
<span className="sr-only">Send to AI</span>
|
<span className="sr-only">Attach Output</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Clear Terminal"
|
aria-label="Clear Terminal"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
DesktopGitDiff,
|
DesktopGitDiff,
|
||||||
DesktopProject,
|
DesktopProject,
|
||||||
DesktopSessionSummary,
|
DesktopSessionSummary,
|
||||||
|
DesktopTerminal,
|
||||||
} from '../../api/client.js';
|
} from '../../api/client.js';
|
||||||
import { createInitialChatState } from '../../stores/chatStore.js';
|
import { createInitialChatState } from '../../stores/chatStore.js';
|
||||||
import { createInitialModelState } from '../../stores/modelStore.js';
|
import { createInitialModelState } from '../../stores/modelStore.js';
|
||||||
|
|
@ -95,6 +96,12 @@ describe('WorkspacePage', () => {
|
||||||
expect(
|
expect(
|
||||||
renderedContainer.querySelector('button[aria-label="Copy Output"]'),
|
renderedContainer.querySelector('button[aria-label="Copy Output"]'),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
renderedContainer.querySelector('button[aria-label="Attach Output"]'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
renderedContainer.querySelector('button[aria-label="Send to AI"]'),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
clickButton(renderedContainer, 'Open Changes');
|
clickButton(renderedContainer, 'Open Changes');
|
||||||
|
|
@ -219,6 +226,30 @@ describe('WorkspacePage', () => {
|
||||||
HTMLFormElement.prototype.requestSubmit = originalRequestSubmit;
|
HTMLFormElement.prototype.requestSubmit = originalRequestSubmit;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes terminal output through an attach action', () => {
|
||||||
|
const onAttachTerminalOutput = vi.fn();
|
||||||
|
const renderedContainer = renderWorkspace({
|
||||||
|
onAttachTerminalOutput,
|
||||||
|
terminal,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
clickButton(renderedContainer, 'Expand Terminal');
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachButton = renderedContainer.querySelector(
|
||||||
|
'button[aria-label="Attach Output"]',
|
||||||
|
);
|
||||||
|
expect(attachButton).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
expect((attachButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
clickButton(renderedContainer, 'Attach Output');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAttachTerminalOutput).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type WorkspacePageProps = Parameters<typeof WorkspacePage>[0];
|
type WorkspacePageProps = Parameters<typeof WorkspacePage>[0];
|
||||||
|
|
@ -270,7 +301,7 @@ function renderWorkspace(
|
||||||
onRevertReviewTarget: vi.fn(),
|
onRevertReviewTarget: vi.fn(),
|
||||||
onRunTerminalCommand: vi.fn(),
|
onRunTerminalCommand: vi.fn(),
|
||||||
onSaveSettings: vi.fn(),
|
onSaveSettings: vi.fn(),
|
||||||
onSendTerminalOutputToAi: vi.fn(),
|
onAttachTerminalOutput: vi.fn(),
|
||||||
onSelectProject: vi.fn(),
|
onSelectProject: vi.fn(),
|
||||||
onSelectSession: vi.fn(),
|
onSelectSession: vi.fn(),
|
||||||
onSendMessage: (event) => event.preventDefault(),
|
onSendMessage: (event) => event.preventDefault(),
|
||||||
|
|
@ -348,6 +379,19 @@ const session: DesktopSessionSummary = {
|
||||||
cwd: project.path,
|
cwd: project.path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const terminal: DesktopTerminal = {
|
||||||
|
id: 'terminal-1',
|
||||||
|
projectId: project.id,
|
||||||
|
cwd: project.path,
|
||||||
|
command: 'printf terminal-output',
|
||||||
|
status: 'exited',
|
||||||
|
output: 'terminal-output',
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
createdAt: '2026-04-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-25T00:00:01.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
const gitDiff: DesktopGitDiff = {
|
const gitDiff: DesktopGitDiff = {
|
||||||
ok: true,
|
ok: true,
|
||||||
generatedAt: '2026-04-25T00:00:00.000Z',
|
generatedAt: '2026-04-25T00:00:00.000Z',
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function WorkspacePage({
|
||||||
onRevertReviewTarget,
|
onRevertReviewTarget,
|
||||||
onRunTerminalCommand,
|
onRunTerminalCommand,
|
||||||
onSaveSettings,
|
onSaveSettings,
|
||||||
onSendTerminalOutputToAi,
|
onAttachTerminalOutput,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -119,7 +119,7 @@ export function WorkspacePage({
|
||||||
onRevertReviewTarget: (target: DesktopGitReviewTarget) => void;
|
onRevertReviewTarget: (target: DesktopGitReviewTarget) => void;
|
||||||
onRunTerminalCommand: () => void;
|
onRunTerminalCommand: () => void;
|
||||||
onSaveSettings: () => void;
|
onSaveSettings: () => void;
|
||||||
onSendTerminalOutputToAi: () => void;
|
onAttachTerminalOutput: () => void;
|
||||||
onSelectProject: (projectId: string) => void;
|
onSelectProject: (projectId: string) => void;
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string) => void;
|
||||||
onSendMessage: (event: FormEvent<HTMLFormElement>) => void;
|
onSendMessage: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
|
@ -259,7 +259,7 @@ export function WorkspacePage({
|
||||||
onKill={onKillTerminal}
|
onKill={onKillTerminal}
|
||||||
onInputChange={onTerminalInputChange}
|
onInputChange={onTerminalInputChange}
|
||||||
onRun={onRunTerminalCommand}
|
onRun={onRunTerminalCommand}
|
||||||
onSendOutputToAi={onSendTerminalOutputToAi}
|
onAttachOutput={onAttachTerminalOutput}
|
||||||
onToggleExpanded={() =>
|
onToggleExpanded={() =>
|
||||||
setIsTerminalExpanded((current) => !current)
|
setIsTerminalExpanded((current) => !current)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue