diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 3341c59da3..66bfd959f4 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -18,6 +18,7 @@
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor } from 'storybook/test';
import { useState, type CSSProperties, type ReactNode } from 'react';
import type { ComponentProps } from 'react';
import type { ProjectRecord } from '@maka/core/project';
@@ -679,6 +680,177 @@ export const InterruptedToolAfterTurnAbort: Story = {
),
};
+// Real path: a tool call fails mid-turn and the turn settles as failed. The
+// errored tool row renders inside `.maka-turn`, and the turn wears its failed
+// Banner (`describeTurnErrorClass('tool_failed')`) with the erroredTool
+// execution-state description — the failed-turn chrome no story exercised.
+export const FailedTurnWithToolError: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ await waitFor(() => {
+ const banner = canvasElement.querySelector('.maka-turn-failed-banner');
+ expect(banner?.textContent).toContain('工具调用失败');
+ expect(banner?.textContent).toContain('这一轮有工具执行出错');
+ });
+ },
+};
+
+// Real path: the provider rate-limits the request and the turn settles failed.
+// The failed Banner carries the rate-limit guidance — the settled provider
+// error a bare transcript never shows.
+export const ProviderRateLimited: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ await waitFor(() =>
+ expect(canvasElement.querySelector('.maka-turn-failed-banner')?.textContent).toContain(
+ '模型请求太频繁被限流了',
+ ),
+ );
+ },
+};
+
+// Real path: the provider throttles a live request and Runtime schedules a
+// retry. The running turn swaps its working phrase for the retry Banner
+// (`ModelProviderRetryIndicator`) — the "retrying" state no story reached.
+export const ProviderRetrying: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ await waitFor(() =>
+ expect(canvasElement.querySelector('.maka-turn-provider-retry')).not.toBeNull(),
+ );
+ },
+};
+
+// Real path: the app restarted mid-turn, so the last turn is failed with
+// errorClass 'app_restarted' and offers safe-resume. The warning-severity
+// Banner carries the 继续这一轮 button (`safeResumeAction`) — the recovery
+// affordance no story reached.
+export const SafeResumeAfterRestart: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ await waitFor(() => {
+ const banner = canvasElement.querySelector('.maka-turn-failed-banner');
+ expect(banner?.textContent).toContain('本地应用重启');
+ expect(banner?.textContent).toContain('继续这一轮');
+ });
+ },
+};
+
+// Real path: a long session with 120 turns — past the transcript virtualizer's
+// window, so it must stay correct and quiet where a handful of seeded turns
+// would never trip the virtualization path.
+export const ManyTurns: Story = {
+ render: () => (
+ {
+ const turnId = `turn-m-${index}`;
+ const minutesAgo = (120 - index) * 3;
+ return [
+ user(`msg-m-u-${index}`, turnId, minutesAgo, `第 ${index + 1} 轮:这个模块的边界条件该怎么覆盖?`),
+ assistant(`msg-m-a-${index}`, turnId, minutesAgo - 1, `第 ${index + 1} 轮回答:先列输入域,再对空、超长、并发三类分别加断言。`),
+ ];
+ }).flat(),
+ }}
+ />
+ ),
+};
+
// Real path: Desktop Computer Use is exposed through the Runtime Host Client
// Capability bridge. The settled observation establishes the confirmed target;
// the following sequence inherits it while live progress replaces the generic