diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 77039bc2a1..8df2bef6ba 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -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/
@@ -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({
@@ -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,
},
@@ -1126,6 +1163,51 @@ export const Terminal: Story = {
render: () => ,
};
+// 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: () => ,
+ 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: () => ,
+};
+
+// 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: () => ,
+};
+
+// 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: () => ,
+ play: async ({ canvasElement }) => {
+ const textarea = await waitFor(() => {
+ const el = canvasElement.querySelector('.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 最近结束.