Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 90 additions & 8 deletions apps/desktop/stories/session-workbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ const SIDE_CHAT_SESSION: SessionSummary = {
const TERMINAL_REF = 'shell-run:storybook-terminal';
const SIDE_CHAT_PANEL_ID = 'storybook-side-chat';

// A terminal whose scrollback runs long — the "very many rows" state a fresh
// shell never shows.
const LONG_TERMINAL_BUFFER = [
'$ npm run build --workspaces',
...Array.from(
{ length: 600 },
(_, index) => `\x1b[2m[${String(index + 1).padStart(4, '0')}]\x1b[0m compiled module ${index + 1}/600`,
),
'$ ',
].join('\r\n');

// Colours, bold, and a carriage-return progress redraw — the control sequences
// a plain "npm test" line never exercises.
const RICH_TERMINAL_BUFFER = [
'$ npm test',
'\x1b[1m\x1b[34mRUNS\x1b[0m packages/ui/src/toast.test.ts',
'\x1b[32m✓\x1b[0m renders every variant \x1b[2m(12 ms)\x1b[0m',
'\x1b[33m●\x1b[0m skipped: flaky under load',
'\x1b[31m✗\x1b[0m confirm queue focus \x1b[2m(3 ms)\x1b[0m',
'\x1b[31m Expected the cancel button to hold focus\x1b[0m',
'Progress: [\x1b[32m##########\x1b[0m----------] 50%\rProgress: [\x1b[32m####################\x1b[0m] 100%',
'\x1b[1mTests:\x1b[0m \x1b[32m41 passed\x1b[0m, \x1b[31m1 failed\x1b[0m, \x1b[33m1 skipped\x1b[0m',
'$ ',
].join('\r\n');

// ---- ledgers -------------------------------------------------------------

// Mirrors the `task-ledger` e2e fixture (apps/desktop/src/main/e2e-fixture/
Expand Down Expand Up @@ -801,6 +826,12 @@ function bridge(options: {
review?: GitReviewReadResult;
/** Make `review.read` reject, so the panel shows its load-error banner. */
reviewFail?: boolean;
/** Make `terminal.attach` resolve to null, so the panel shows its load-failed Banner. */
terminalAttach?: 'missing';
/** The scrollback buffer the attached terminal hydrates with. */
terminalBuffer?: string;
/** Make `terminal.write` reject, so typing into the terminal shows the write-failed Banner. */
terminalWriteFails?: boolean;
} = {}): Decorator {
const browserState = options.browserState ?? EMPTY_BROWSER_STATE;
const services = createFakeWorkbarServices({
Expand Down Expand Up @@ -862,15 +893,21 @@ function bridge(options: {
throw new Error('Terminal stories mount an existing resource');
},
stop: async () => null,
attach: async () => ({
sessionId: SESSION_ID,
ref: TERMINAL_REF,
sequence: 1,
buffer: '$ npm test\r\n✓ workbar controller\r\n',
size: { cols: 80, rows: 24 },
}),
attach: async () => {
if (options.terminalAttach === 'missing') return null;
return {
sessionId: SESSION_ID,
ref: TERMINAL_REF,
sequence: 1,
buffer: options.terminalBuffer ?? '$ npm test\r\n✓ workbar controller\r\n',
size: { cols: 80, rows: 24 },
};
},
detach: async () => undefined,
write: async () => null,
write: async () => {
if (options.terminalWriteFails) throw new Error('write failed');
return null;
},
subscribePtyData: unsubscribe,
subscribeResync: unsubscribe,
},
Expand Down Expand Up @@ -1126,6 +1163,51 @@ export const Terminal: Story = {
render: () => <Workbar tab="terminal" />,
};

// Real path: 任务工作栏 → 终端 when `terminal.attach` finds no live resource
// (it resolves to null); the panel raises its load-failed Banner over the
// terminal surface instead of a silently blank pane.
export const TerminalLoadFailed: Story = {
decorators: [bridge({ terminalAttach: 'missing' })],
render: () => <Workbar tab="terminal" />,
play: async ({ canvasElement }) => {
await waitFor(() => expect(canvasElement.textContent).toContain('无法读取终端运行'));
},
};

// Real path: 任务工作栏 → 终端 after a long-running build — hundreds of lines of
// scrollback the fresh-shell story never shows.
export const TerminalLongScrollback: Story = {
decorators: [bridge({ terminalBuffer: LONG_TERMINAL_BUFFER })],
render: () => <Workbar tab="terminal" />,
};

// Real path: 任务工作栏 → 终端 rendering coloured, bold, and carriage-return
// progress output — the ANSI control sequences a plain command line omits.
export const TerminalRichOutput: Story = {
decorators: [bridge({ terminalBuffer: RICH_TERMINAL_BUFFER })],
render: () => <Workbar tab="terminal" />,
};

// Real path: 任务工作栏 → 终端 when a keystroke cannot reach the PTY
// (`terminal.write` rejects). Typing surfaces the panel's write-failed Banner.
// Input is driven through xterm's own helper textarea — the same handle the
// accessibility-runtime story uses — so this is genuinely reachable from a
// mounted story.
export const TerminalWriteFailed: Story = {
decorators: [bridge({ terminalWriteFails: true })],
render: () => <Workbar tab="terminal" />,
play: async ({ canvasElement }) => {
const textarea = await waitFor(() => {
const el = canvasElement.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea');
if (!el) throw new Error('xterm helper textarea not ready');
return el;
});
textarea.focus();
await userEvent.keyboard('echo hi');
await waitFor(() => expect(canvasElement.textContent).toContain('无法发送终端输入'));
},
};

// Real path: sidebar → a session → 展开任务工作栏, landing on the tab the app
// restored. Tasks is the default: an in-progress root, a child claimed and
// blocked by a subagent, and the finished ones folded into 最近结束.
Expand Down