From c8481e3eccccf647a0fef3734d939c7d7bb26c81 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 13:34:09 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20split=20Storyboo?= =?UTF-8?q?k=20into=20focused=20full-app=20story=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate all component stories into full-app stories, then split the mega-file into focused story files organized by functional area. src/browser/stories/ ├── mockFactory.ts (542 lines) - Composable mock builders ├── storyHelpers.ts (189 lines) - Reusable setup patterns ├── meta.tsx (47 lines) - Shared Storybook config ├── App.welcome.stories.tsx - Empty/welcome states ├── App.sidebar.stories.tsx - Project navigation, git status ├── App.chat.stories.tsx - Conversations, tools, reasoning ├── App.markdown.stories.tsx - Tables, code blocks ├── App.media.stories.tsx - Image attachments ├── App.errors.stories.tsx - Stream errors, hidden history └── App.demo.stories.tsx - Comprehensive feature demo - setupSimpleChatStory() - One workspace with static messages - setupStreamingChatStory() - Active streaming state - setupCustomChatStory() - Custom chat handler for edge cases - selectWorkspace() - Set localStorage for workspace selection Each story file is now ~100-200 lines, focused on a single area. _Generated with `mux`_ --- src/browser/App.stories.tsx | 1733 ----------------- .../components/CommandPalette.stories.tsx | 208 -- .../components/ErrorMessage.stories.tsx | 73 - .../GitStatusIndicatorView.stories.tsx | 375 ---- src/browser/components/KebabMenu.stories.tsx | 84 - .../Messages/AssistantMessage.stories.tsx | 262 --- .../Messages/HistoryHiddenMessage.stories.tsx | 63 - .../Messages/ReasoningMessage.stories.tsx | 147 -- .../Messages/StreamErrorMessage.stories.tsx | 149 -- .../Messages/TerminalOutput.stories.tsx | 128 -- .../Messages/UserMessage.stories.tsx | 139 -- src/browser/components/Modal.stories.tsx | 350 ---- .../components/ModelSelector.stories.tsx | 94 - .../components/ProjectCreateModal.stories.tsx | 445 ----- .../CodeReview/ReviewPanel.stories.tsx | 486 ----- .../components/Settings/Settings.stories.tsx | 295 --- .../components/ThinkingSlider.stories.tsx | 168 -- .../components/TipsCarousel.stories.tsx | 76 - .../components/ToggleGroup.stories.tsx | 335 ---- src/browser/components/Tooltip.stories.tsx | 195 -- .../components/TutorialTooltip.stories.tsx | 232 --- .../components/ui/CopyButton.stories.tsx | 79 - src/browser/stories/App.chat.stories.tsx | 240 +++ src/browser/stories/App.demo.stories.tsx | 227 +++ src/browser/stories/App.errors.stories.tsx | 240 +++ src/browser/stories/App.markdown.stories.tsx | 124 ++ src/browser/stories/App.media.stories.tsx | 44 + src/browser/stories/App.sidebar.stories.tsx | 177 ++ src/browser/stories/App.welcome.stories.tsx | 27 + src/browser/stories/meta.tsx | 47 + src/browser/stories/mockFactory.ts | 559 ++++++ src/browser/stories/storyHelpers.ts | 189 ++ 32 files changed, 1874 insertions(+), 6116 deletions(-) delete mode 100644 src/browser/App.stories.tsx delete mode 100644 src/browser/components/CommandPalette.stories.tsx delete mode 100644 src/browser/components/ErrorMessage.stories.tsx delete mode 100644 src/browser/components/GitStatusIndicatorView.stories.tsx delete mode 100644 src/browser/components/KebabMenu.stories.tsx delete mode 100644 src/browser/components/Messages/AssistantMessage.stories.tsx delete mode 100644 src/browser/components/Messages/HistoryHiddenMessage.stories.tsx delete mode 100644 src/browser/components/Messages/ReasoningMessage.stories.tsx delete mode 100644 src/browser/components/Messages/StreamErrorMessage.stories.tsx delete mode 100644 src/browser/components/Messages/TerminalOutput.stories.tsx delete mode 100644 src/browser/components/Messages/UserMessage.stories.tsx delete mode 100644 src/browser/components/Modal.stories.tsx delete mode 100644 src/browser/components/ModelSelector.stories.tsx delete mode 100644 src/browser/components/ProjectCreateModal.stories.tsx delete mode 100644 src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx delete mode 100644 src/browser/components/Settings/Settings.stories.tsx delete mode 100644 src/browser/components/ThinkingSlider.stories.tsx delete mode 100644 src/browser/components/TipsCarousel.stories.tsx delete mode 100644 src/browser/components/ToggleGroup.stories.tsx delete mode 100644 src/browser/components/Tooltip.stories.tsx delete mode 100644 src/browser/components/TutorialTooltip.stories.tsx delete mode 100644 src/browser/components/ui/CopyButton.stories.tsx create mode 100644 src/browser/stories/App.chat.stories.tsx create mode 100644 src/browser/stories/App.demo.stories.tsx create mode 100644 src/browser/stories/App.errors.stories.tsx create mode 100644 src/browser/stories/App.markdown.stories.tsx create mode 100644 src/browser/stories/App.media.stories.tsx create mode 100644 src/browser/stories/App.sidebar.stories.tsx create mode 100644 src/browser/stories/App.welcome.stories.tsx create mode 100644 src/browser/stories/meta.tsx create mode 100644 src/browser/stories/mockFactory.ts create mode 100644 src/browser/stories/storyHelpers.ts diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx deleted file mode 100644 index d5a27e7756..0000000000 --- a/src/browser/App.stories.tsx +++ /dev/null @@ -1,1733 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useRef } from "react"; -import { AppLoader } from "./components/AppLoader"; -import type { ProjectConfig } from "@/node/config"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { IPCApi } from "@/common/types/ipc"; -import type { ChatStats } from "@/common/types/chatStats"; -import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; - -// Stable timestamp for testing active states (use fixed time minus small offsets) -// This ensures workspaces don't show as "Older than 1 day" and keeps stories deterministic -const NOW = 1700000000000; // Fixed timestamp: Nov 14, 2023 -const STABLE_TIMESTAMP = NOW - 60000; // 1 minute ago - -// Mock window.api for App component -function setupMockAPI(options: { - projects?: Map; - workspaces?: FrontendWorkspaceMetadata[]; - selectedWorkspaceId?: string; - apiOverrides?: Partial; -}) { - const mockProjects = options.projects ?? new Map(); - const mockWorkspaces = options.workspaces ?? []; - const mockStats: ChatStats = { - consumers: [], - totalTokens: 0, - model: "mock-model", - tokenizerName: "mock-tokenizer", - usageHistory: [], - }; - - const mockApi: IPCApi = { - tokenizer: { - countTokens: () => Promise.resolve(0), - countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 0)), - calculateStats: () => Promise.resolve(mockStats), - }, - providers: { - setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), - setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => - Promise.resolve( - {} as Record - ), - list: () => Promise.resolve([]), - }, - workspace: { - create: (projectPath: string, branchName: string) => - Promise.resolve({ - success: true, - metadata: { - // Mock stable ID (production uses crypto.randomBytes(5).toString('hex')) - id: Math.random().toString(36).substring(2, 12), - name: branchName, - projectPath, - projectName: projectPath.split("/").pop() ?? "project", - namedWorkspacePath: `/mock/workspace/${branchName}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - }), - list: () => Promise.resolve(mockWorkspaces), - rename: (workspaceId: string) => - Promise.resolve({ - success: true, - data: { newWorkspaceId: workspaceId }, - }), - remove: () => Promise.resolve({ success: true }), - fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), - openTerminal: () => Promise.resolve(undefined), - onChat: () => () => undefined, - onMetadata: () => () => undefined, - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - clearQueue: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - activity: { - list: () => Promise.resolve({}), - subscribe: () => () => undefined, - }, - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: () => - Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }), - }, - projects: { - list: () => Promise.resolve(Array.from(mockProjects.entries())), - create: () => - Promise.resolve({ - success: true, - data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" }, - }), - remove: () => Promise.resolve({ success: true, data: undefined }), - pickDirectory: () => Promise.resolve(null), - listBranches: () => - Promise.resolve({ - branches: ["main", "develop", "feature/new-feature"], - recommendedTrunk: "main", - }), - secrets: { - get: () => Promise.resolve([]), - update: () => Promise.resolve({ success: true, data: undefined }), - }, - }, - window: { - setTitle: () => Promise.resolve(undefined), - }, - terminal: { - create: () => - Promise.resolve({ - sessionId: "mock-session", - workspaceId: "mock-workspace", - cols: 80, - rows: 24, - }), - close: () => Promise.resolve(undefined), - resize: () => Promise.resolve(undefined), - sendInput: () => undefined, - onOutput: () => () => undefined, - onExit: () => () => undefined, - openWindow: () => Promise.resolve(undefined), - closeWindow: () => Promise.resolve(undefined), - }, - update: { - check: () => Promise.resolve(undefined), - download: () => Promise.resolve(undefined), - install: () => undefined, - onStatus: () => () => undefined, - }, - ...options.apiOverrides, - }; - - // @ts-expect-error - Assigning mock API to window for Storybook - window.api = mockApi; -} - -const meta = { - title: "App/Full Application", - component: AppLoader, - parameters: { - layout: "fullscreen", - backgrounds: { - default: "dark", - values: [{ name: "dark", value: "#1e1e1e" }], - }, - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// Story wrapper that sets up mocks synchronously before rendering -const AppWithMocks: React.FC<{ - projects?: Map; - workspaces?: FrontendWorkspaceMetadata[]; - selectedWorkspaceId?: string; -}> = ({ projects, workspaces, selectedWorkspaceId }) => { - // Set up mock API only once per component instance (not on every render) - // Use useRef to ensure it runs synchronously before first render - const initialized = useRef(false); - if (!initialized.current) { - setupMockAPI({ projects, workspaces, selectedWorkspaceId }); - initialized.current = true; - } - - return ; -}; - -export const WelcomeScreen: Story = { - render: () => , -}; - -export const SingleProject: Story = { - render: () => { - const projects = new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/main", id: "a1b2c3d4e5", name: "main" }, - { - path: "/home/user/.mux/src/my-app/feature-auth", - id: "f6g7h8i9j0", - name: "feature/auth", - }, - { - path: "/home/user/.mux/src/my-app/bugfix", - id: "k1l2m3n4o5", - name: "bugfix/memory-leak", - }, - ], - }, - ], - ]); - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: "a1b2c3d4e5", - name: "main", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - { - id: "f6g7h8i9j0", - name: "feature/auth", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature-auth", - runtimeConfig: { - type: "ssh", - host: "dev-server.example.com", - srcBaseDir: "/home/user/.mux/src", - }, - }, - { - id: "my-app-bugfix", - name: "bugfix/memory-leak", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/bugfix", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - ]; - - return ; - }, -}; - -export const MultipleProjects: Story = { - render: () => { - // Note: Workspace IDs are fixtures using hex-like format (production uses random 10-hex chars) - const projects = new Map([ - [ - "/home/user/projects/frontend", - { - workspaces: [ - { path: "/home/user/.mux/src/frontend/main", id: "1a2b3c4d5e", name: "main" }, - { - path: "/home/user/.mux/src/frontend/redesign", - id: "2b3c4d5e6f", - name: "redesign", - }, - ], - }, - ], - [ - "/home/user/projects/backend", - { - workspaces: [ - { path: "/home/user/.mux/src/backend/main", id: "3c4d5e6f7a", name: "main" }, - { path: "/home/user/.mux/src/backend/api-v2", id: "4d5e6f7a8b", name: "api-v2" }, - { - path: "/home/user/.mux/src/backend/db-migration", - id: "5e6f7a8b9c", - name: "db-migration", - }, - ], - }, - ], - [ - "/home/user/projects/mobile", - { - workspaces: [{ path: "/home/user/.mux/src/mobile/main", id: "6f7a8b9c0d", name: "main" }], - }, - ], - ]); - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: "1a2b3c4d5e", - name: "main", - projectPath: "/home/user/projects/frontend", - projectName: "frontend", - namedWorkspacePath: "/home/user/.mux/src/frontend/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - { - id: "2b3c4d5e6f", - name: "redesign", - projectPath: "/home/user/projects/frontend", - projectName: "frontend", - namedWorkspacePath: "/home/user/.mux/src/frontend/redesign", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - { - id: "3c4d5e6f7a", - name: "main", - projectPath: "/home/user/projects/backend", - projectName: "backend", - namedWorkspacePath: "/home/user/.mux/src/backend/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - { - id: "4d5e6f7a8b", - name: "api-v2", - projectPath: "/home/user/projects/backend", - projectName: "backend", - namedWorkspacePath: "/home/user/.mux/src/backend/api-v2", - runtimeConfig: { - type: "ssh", - host: "prod-server.example.com", - srcBaseDir: "/home/user/.mux/src", - }, - }, - { - id: "5e6f7a8b9c", - name: "db-migration", - projectPath: "/home/user/projects/backend", - projectName: "backend", - namedWorkspacePath: "/home/user/.mux/src/backend/db-migration", - runtimeConfig: { - type: "ssh", - host: "staging.example.com", - srcBaseDir: "/home/user/.mux/src", - }, - }, - { - id: "6f7a8b9c0d", - name: "main", - projectPath: "/home/user/projects/mobile", - projectName: "mobile", - namedWorkspacePath: "/home/user/.mux/src/mobile/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - ]; - - return ; - }, -}; - -export const ManyWorkspaces: Story = { - render: () => { - const workspaceNames = [ - "main", - "develop", - "staging", - "feature/authentication", - "feature/dashboard", - "feature/notifications", - "feature/search", - "bugfix/memory-leak", - "bugfix/login-redirect", - "refactor/components", - "experiment/new-ui", - "release/v1.2.0", - ]; - - const projects = new Map([ - [ - "/home/user/projects/big-app", - { - workspaces: workspaceNames.map((name) => ({ - path: `/home/user/.mux/src/big-app/${name}`, - id: `big-app-${name}`, - name, - })), - }, - ], - ]); - - const workspaces: FrontendWorkspaceMetadata[] = workspaceNames.map((name) => ({ - id: `big-app-${name}`, - name, - projectPath: "/home/user/projects/big-app", - projectName: "big-app", - namedWorkspacePath: `/home/user/.mux/src/big-app/${name}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - })); - - return ; - }, -}; - -/** - * Story demonstrating the incompatible workspace error view. - * - * When a user downgrades to an older version of mux that doesn't support - * a workspace's runtime configuration, the workspace shows an error message - * instead of crashing. This ensures graceful degradation. - */ -export const IncompatibleWorkspace: Story = { - render: () => { - const AppWithIncompatibleWorkspace = () => { - const initialized = useRef(false); - - if (!initialized.current) { - const workspaceId = "incompatible-ws"; - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: "my-app-main", - name: "main", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - { - id: workspaceId, - name: "incompatible", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/incompatible", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - // This field is set when a workspace has an incompatible runtime config - incompatibleRuntime: - "This workspace was created with a newer version of mux.\nPlease upgrade mux to use this workspace.", - }, - ]; - - setupMockAPI({ - projects: new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/main", id: "my-app-main", name: "main" }, - { - path: "/home/user/.mux/src/my-app/incompatible", - id: workspaceId, - name: "incompatible", - }, - ], - }, - ], - ]), - workspaces, - }); - - // Set initial workspace selection to the incompatible workspace - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/incompatible", - }) - ); - - initialized.current = true; - } - - return ; - }; - - return ; - }, -}; - -/** - * Story demonstrating all possible UI indicators in the project sidebar. - * - * This story showcases: - * - * **Active Workspace (feature/auth)** - * - Chat history with various message types - * - Agent status indicator (🚀 emoji with PR link) - * - Git status: dirty (uncommitted changes) + ahead/behind - * - All tool types: read_file, search_replace, run_terminal_cmd, status_set - * - Reasoning blocks (thinking) - * - Local runtime - * - * **Streaming Workspace (feature/streaming)** ⚡ - * - **ACTIVELY WORKING** - shows the streaming/working state - * - Incomplete assistant message with tool call in progress - * - Model indicator showing current model - * - Git status: dirty (1 uncommitted file) - * - Use this to see what an active workspace looks like! - * - * **Other Workspaces (Git Status Variations)** - * - **main**: Clean (no git indicators) - * - **feature/new-ui**: Ahead of origin - * - **feature/api**: Behind origin - * - **bugfix/crash**: Dirty (uncommitted changes) - * - **refactor/db**: Diverged (ahead + behind + dirty) - * - **deploy/prod**: SSH runtime + git status - * - * **UI Indicators Shown** - * - GitStatusIndicator: ↑ ahead, ↓ behind, * dirty - * - AgentStatusIndicator: streaming, unread, agent status emoji - * - RuntimeBadge: SSH vs local - * - Active workspace highlight - * - * Use this story to test sidebar redesigns and ensure all data is visible. - */ -export const ActiveWorkspaceWithChat: Story = { - render: () => { - const workspaceId = "demo-workspace"; - - // Create multiple workspaces showcasing all UI variations - const streamingWorkspaceId = "ws-streaming"; - const projects = new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "feature/auth" }, - { - path: "/home/user/.mux/src/my-app/streaming", - id: streamingWorkspaceId, - name: "feature/streaming", - }, - { path: "/home/user/.mux/src/my-app/main", id: "ws-clean", name: "main" }, - { path: "/home/user/.mux/src/my-app/ahead", id: "ws-ahead", name: "feature/new-ui" }, - { path: "/home/user/.mux/src/my-app/behind", id: "ws-behind", name: "feature/api" }, - { path: "/home/user/.mux/src/my-app/dirty", id: "ws-dirty", name: "bugfix/crash" }, - { - path: "/home/user/.mux/src/my-app/diverged", - id: "ws-diverged", - name: "refactor/db", - }, - { path: "/home/user/.mux/src/my-app/ssh-remote", id: "ws-ssh", name: "deploy/prod" }, - ], - }, - ], - [ - "/home/user/projects/another-app", - { - workspaces: [], - }, - ], - ]); - - const workspaces: FrontendWorkspaceMetadata[] = [ - // Active workspace with chat, streaming, and agent status - { - id: workspaceId, - name: "feature/auth", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 7200000).toISOString(), // 2 hours ago - }, - // Workspace actively streaming (working state) - { - id: streamingWorkspaceId, - name: "feature/streaming", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/streaming", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 3600000).toISOString(), // 1 hour ago - }, - // Clean workspace (no git indicators) - { - id: "ws-clean", - name: "main", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/main", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 10800000).toISOString(), // 3 hours ago - }, - // Ahead of origin - { - id: "ws-ahead", - name: "feature/new-ui", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/ahead", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 14400000).toISOString(), // 4 hours ago - }, - // Behind origin - { - id: "ws-behind", - name: "feature/api", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/behind", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 18000000).toISOString(), // 5 hours ago - }, - // Dirty (uncommitted changes) - { - id: "ws-dirty", - name: "bugfix/crash", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/dirty", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 21600000).toISOString(), // 6 hours ago - }, - // Diverged (ahead + behind + dirty) - { - id: "ws-diverged", - name: "refactor/db", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/diverged", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 25200000).toISOString(), // 7 hours ago - }, - // SSH workspace - { - id: "ws-ssh", - name: "deploy/prod", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/ssh-remote", - runtimeConfig: { - type: "ssh", - host: "prod.example.com", - srcBaseDir: "/home/deploy/.mux/src", - }, - createdAt: new Date(NOW - 28800000).toISOString(), // 8 hours ago - }, - ]; - - const AppWithChatMocks: React.FC = () => { - // Set up mock API only once per component instance (not on every render) - const initialized = useRef(false); - if (!initialized.current) { - setupMockAPI({ - projects, - workspaces, - apiOverrides: { - tokenizer: { - countTokens: () => Promise.resolve(42), - countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 42)), - calculateStats: () => - Promise.resolve({ - consumers: [], - totalTokens: 0, - model: "mock-model", - tokenizerName: "mock-tokenizer", - usageHistory: [], - }), - }, - providers: { - setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), - setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => - Promise.resolve( - {} as Record - ), - list: () => Promise.resolve(["anthropic", "openai", "xai"]), - }, - workspace: { - create: (projectPath: string, branchName: string) => - Promise.resolve({ - success: true, - metadata: { - // Mock stable ID (production uses crypto.randomBytes(5).toString('hex')) - id: Math.random().toString(36).substring(2, 12), - name: branchName, - projectPath, - projectName: projectPath.split("/").pop() ?? "project", - namedWorkspacePath: `/mock/workspace/${branchName}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - }), - list: () => Promise.resolve(workspaces), - rename: (workspaceId: string) => - Promise.resolve({ - success: true, - data: { newWorkspaceId: workspaceId }, - }), - remove: () => Promise.resolve({ success: true }), - fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), - openTerminal: () => Promise.resolve(undefined), - onChat: (wsId, callback) => { - // Active workspace with complete chat history - if (wsId === workspaceId) { - setTimeout(() => { - // User message - callback({ - id: "msg-1", - role: "user", - parts: [ - { type: "text", text: "Add authentication to the user API endpoint" }, - ], - metadata: { - historySequence: 1, - timestamp: STABLE_TIMESTAMP - 300000, - }, - }); - - // Assistant message with tool calls - callback({ - id: "msg-2", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll help you add authentication to the user API endpoint. Let me first check the current implementation.", - }, - { - type: "dynamic-tool", - toolCallId: "call-1", - toolName: "read_file", - state: "output-available", - input: { target_file: "src/api/users.ts" }, - output: { - success: true, - content: - "export function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", - }, - }, - ], - metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP - 290000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1250, - outputTokens: 450, - totalTokens: 1700, - }, - duration: 3500, - }, - }); - - // User response - callback({ - id: "msg-3", - role: "user", - parts: [{ type: "text", text: "Yes, add JWT token validation" }], - metadata: { - historySequence: 3, - timestamp: STABLE_TIMESTAMP - 280000, - }, - }); - - // Assistant message with file edit (large diff) - callback({ - id: "msg-4", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll add JWT token validation to the endpoint. Let me update the file with proper authentication middleware and error handling.", - }, - { - type: "dynamic-tool", - toolCallId: "call-2", - toolName: "file_edit_replace_string", - state: "output-available", - input: { - file_path: "src/api/users.ts", - old_string: - "import express from 'express';\nimport { db } from '../db';\n\nexport function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", - new_string: - "import express from 'express';\nimport { db } from '../db';\nimport { verifyToken } from '../auth/jwt';\nimport { logger } from '../utils/logger';\n\nexport async function getUser(req, res) {\n try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token) {\n logger.warn('Missing authorization token');\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const decoded = await verifyToken(token);\n const user = await db.users.find(req.params.id);\n res.json(user);\n } catch (err) {\n logger.error('Auth error:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }\n}", - }, - output: { - success: true, - diff: [ - "--- src/api/users.ts", - "+++ src/api/users.ts", - "@@ -2,0 +3,2 @@", - "+import { verifyToken } from '../auth/jwt';", - "+import { logger } from '../utils/logger';", - "@@ -4,28 +6,14 @@", - "-// TODO: Add authentication middleware", - "-// Current implementation is insecure and allows unauthorized access", - "-// Need to validate JWT tokens before processing requests", - "-// Also need to add rate limiting to prevent abuse", - "-// Consider adding request logging for audit trail", - "-// Add input validation for user IDs", - "-// Handle edge cases for deleted/suspended users", - "-", - "-/**", - "- * Get user by ID", - "- * @param {Object} req - Express request object", - "- * @param {Object} res - Express response object", - "- */", - "-export function getUser(req, res) {", - "- // FIXME: No authentication check", - "- // FIXME: No error handling", - "- // FIXME: Synchronous database call blocks event loop", - "- // FIXME: No input validation", - "- // FIXME: Direct database access without repository pattern", - "- // FIXME: No logging", - "-", - "- const user = db.users.find(req.params.id);", - "-", - "- // TODO: Check if user exists", - "- // TODO: Filter sensitive fields (password hash, etc)", - "- // TODO: Check permissions - user should only access their own data", - "-", - "- res.json(user);", - "+export async function getUser(req, res) {", - "+ try {", - "+ const token = req.headers.authorization?.split(' ')[1];", - "+ if (!token) {", - "+ logger.warn('Missing authorization token');", - "+ return res.status(401).json({ error: 'Unauthorized' });", - "+ }", - "+ const decoded = await verifyToken(token);", - "+ const user = await db.users.find(req.params.id);", - "+ res.json(user);", - "+ } catch (err) {", - "+ logger.error('Auth error:', err);", - "+ return res.status(401).json({ error: 'Invalid token' });", - "+ }", - "@@ -34,3 +22,2 @@", - "-// TODO: Add updateUser function", - "-// TODO: Add deleteUser function", - "-// TODO: Add listUsers function with pagination", - "+// Note: updateUser, deleteUser, and listUsers endpoints will be added in separate PR", - "+// to keep changes focused and reviewable", - "@@ -41,0 +29,11 @@", - "+", - "+export async function rotateApiKey(req, res) {", - "+ const admin = await db.admins.find(req.user.id);", - "+ if (!admin) {", - "+ return res.status(403).json({ error: 'Forbidden' });", - "+ }", - "+", - "+ const apiKey = await db.tokens.rotate(admin.orgId);", - "+ logger.info('Rotated API key', { orgId: admin.orgId });", - "+ res.json({ apiKey });", - "+}", - ].join("\n"), - edits_applied: 1, - }, - }, - ], - metadata: { - historySequence: 4, - timestamp: STABLE_TIMESTAMP - 270000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 2100, - outputTokens: 680, - totalTokens: 2780, - }, - duration: 4200, - }, - }); - - // Assistant with code block example - callback({ - id: "msg-5", - role: "assistant", - parts: [ - { - type: "text", - text: "Perfect! I've added JWT authentication. Here's what the updated endpoint looks like:\n\n```typescript\nimport { verifyToken } from '../auth/jwt';\n\nexport function getUser(req, res) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const user = db.users.find(req.params.id);\n res.json(user);\n}\n```\n\nThe endpoint now requires a valid JWT token in the Authorization header. Let me run the tests to verify everything works.", - }, - ], - metadata: { - historySequence: 5, - timestamp: STABLE_TIMESTAMP - 260000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1800, - outputTokens: 520, - totalTokens: 2320, - }, - duration: 3200, - }, - }); - - // User asking to run tests - callback({ - id: "msg-6", - role: "user", - parts: [ - { type: "text", text: "Can you run the tests to make sure it works?" }, - ], - metadata: { - historySequence: 6, - timestamp: STABLE_TIMESTAMP - 240000, - }, - }); - - // Assistant running tests - callback({ - id: "msg-7", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll run the tests to verify the authentication is working correctly.", - }, - { - type: "dynamic-tool", - toolCallId: "call-3", - toolName: "run_terminal_cmd", - state: "output-available", - input: { - command: "npm test src/api/users.test.ts", - explanation: "Running tests for the users API endpoint", - }, - output: { - success: true, - stdout: - "PASS src/api/users.test.ts\n ✓ should return user when authenticated (24ms)\n ✓ should return 401 when no token (18ms)\n ✓ should return 401 when invalid token (15ms)\n\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total", - exitCode: 0, - }, - }, - ], - metadata: { - historySequence: 7, - timestamp: STABLE_TIMESTAMP - 230000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 2800, - outputTokens: 420, - totalTokens: 3220, - }, - duration: 5100, - }, - }); - - // User follow-up about error handling - callback({ - id: "msg-8", - role: "user", - parts: [ - { - type: "text", - text: "Great! What about error handling if the JWT library throws?", - }, - ], - metadata: { - historySequence: 8, - timestamp: STABLE_TIMESTAMP - 180000, - }, - }); - - // Assistant response with thinking (reasoning) - callback({ - id: "msg-9", - role: "assistant", - parts: [ - { - type: "reasoning", - text: "The user is asking about error handling for JWT verification. The verifyToken function could throw if the token is malformed or if there's an issue with the secret. I should wrap it in a try-catch block and return a proper error response.", - }, - { - type: "text", - text: "Good catch! We should add try-catch error handling around the JWT verification. Let me update that.", - }, - { - type: "dynamic-tool", - toolCallId: "call-4", - toolName: "search_replace", - state: "output-available", - input: { - file_path: "src/api/users.ts", - old_string: - " const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }", - new_string: - " try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n } catch (err) {\n console.error('Token verification failed:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }", - }, - output: { - success: true, - message: "File updated successfully", - }, - }, - ], - metadata: { - historySequence: 9, - timestamp: STABLE_TIMESTAMP - 170000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 3500, - outputTokens: 520, - totalTokens: 4020, - reasoningTokens: 150, - }, - duration: 6200, - }, - }); - - // Assistant quick update with a single-line reasoning trace to exercise inline display - callback({ - id: "msg-9a", - role: "assistant", - parts: [ - { - type: "reasoning", - text: "Cache is warm already; rerunning the full suite would be redundant.", - }, - { - type: "text", - text: "Cache is warm from the last test run, so I'll shift focus to documentation next.", - }, - ], - metadata: { - historySequence: 10, - timestamp: STABLE_TIMESTAMP - 165000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1200, - outputTokens: 180, - totalTokens: 1380, - reasoningTokens: 20, - }, - duration: 900, - }, - }); - - // Assistant message with status_set tool to show agent status - callback({ - id: "msg-10", - role: "assistant", - parts: [ - { - type: "text", - text: "I've created PR #1234 with the authentication changes. The CI pipeline is running tests now.", - }, - { - type: "dynamic-tool", - toolCallId: "call-5", - toolName: "status_set", - state: "output-available", - input: { - emoji: "🚀", - message: "PR #1234 waiting for CI", - url: "https://github.com/example/repo/pull/1234", - }, - output: { - success: true, - emoji: "🚀", - message: "PR #1234 waiting for CI", - url: "https://github.com/example/repo/pull/1234", - }, - }, - ], - metadata: { - historySequence: 11, - timestamp: STABLE_TIMESTAMP - 160000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 800, - outputTokens: 150, - totalTokens: 950, - }, - duration: 1200, - }, - }); - - // User follow-up asking about documentation - callback({ - id: "msg-11", - role: "user", - parts: [ - { - type: "text", - text: "Should we add documentation for the authentication changes?", - }, - ], - metadata: { - historySequence: 12, - timestamp: STABLE_TIMESTAMP - 150000, - }, - }); - - // Mark as caught up - callback({ type: "caught-up" }); - - // Now start streaming assistant response with reasoning - callback({ - type: "stream-start", - workspaceId: workspaceId, - messageId: "msg-12", - model: "anthropic:claude-sonnet-4-5", - historySequence: 13, - }); - - // Send reasoning delta - callback({ - type: "reasoning-delta", - workspaceId: workspaceId, - messageId: "msg-12", - delta: - "The user is asking about documentation. This is important because the authentication changes introduce a breaking change for API clients. They'll need to know how to include JWT tokens in their requests. I should suggest adding both inline code comments and updating the API documentation to explain the new authentication requirements, including examples of how to obtain and use tokens.", - tokens: 65, - timestamp: STABLE_TIMESTAMP - 140000, - }); - }, 100); - - // Keep sending reasoning deltas to maintain streaming state - // tokens: 0 to avoid flaky token counts in visual tests - const intervalId = setInterval(() => { - callback({ - type: "reasoning-delta", - workspaceId: workspaceId, - messageId: "msg-12", - delta: ".", - tokens: 0, - timestamp: NOW, - }); - }, 2000); - - return () => { - clearInterval(intervalId); - }; - } else if (wsId === streamingWorkspaceId) { - // Streaming workspace - show active work in progress - setTimeout(() => { - const now = NOW; // Use stable timestamp - - // Previous completed message with status_set (MUST be sent BEFORE caught-up) - callback({ - id: "stream-msg-0", - role: "assistant", - parts: [ - { - type: "text", - text: "I'm working on the database refactoring.", - }, - { - type: "dynamic-tool", - toolCallId: "status-call-0", - toolName: "status_set", - state: "output-available", - input: { - emoji: "⚙️", - message: "Refactoring in progress", - }, - output: { - success: true, - emoji: "⚙️", - message: "Refactoring in progress", - }, - }, - ], - metadata: { - historySequence: 0, - timestamp: now - 5000, // 5 seconds ago - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 200, - outputTokens: 50, - totalTokens: 250, - }, - duration: 800, - }, - }); - - // User message (recent) - callback({ - id: "stream-msg-1", - role: "user", - parts: [ - { - type: "text", - text: "Refactor the database connection to use connection pooling", - }, - ], - metadata: { - historySequence: 1, - timestamp: now - 3000, // 3 seconds ago - }, - }); - - // CRITICAL: Send caught-up AFTER historical messages so they get processed! - // Streaming state is maintained by continuous stream-delta events, not by withholding caught-up - callback({ type: "caught-up" }); - - // Now send stream events - they'll be processed immediately - // Stream start event (very recent - just started) - callback({ - type: "stream-start", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - model: "anthropic:claude-sonnet-4-5", - historySequence: 2, - }); - - // Stream delta event - shows text being typed out (just happened) - callback({ - type: "stream-delta", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - delta: - "I'll help you refactor the database connection to use connection pooling.", - tokens: 15, - timestamp: now - 1000, // 1 second ago - }); - - // Tool call start event - shows tool being invoked (happening now) - callback({ - type: "tool-call-start", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - toolCallId: "stream-call-1", - toolName: "read_file", - args: { target_file: "src/db/connection.ts" }, - tokens: 8, - timestamp: now - 500, // 0.5 seconds ago - }); - }, 100); - - // Keep sending deltas to maintain streaming state - // tokens: 0 to avoid flaky token counts in visual tests - const intervalId = setInterval(() => { - callback({ - type: "stream-delta", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - delta: ".", - tokens: 0, - timestamp: NOW, - }); - }, 2000); - - // Return cleanup function that stops the interval - return () => clearInterval(intervalId); - } else { - // Other workspaces - send caught-up immediately - setTimeout(() => { - callback({ type: "caught-up" }); - }, 100); - - return () => { - // Cleanup - }; - } - }, - onMetadata: () => () => undefined, - activity: { - list: () => Promise.resolve({}), - subscribe: () => () => undefined, - }, - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - clearQueue: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: (wsId: string, command: string) => { - // Mock git status script responses for each workspace - const gitStatusMocks: Record = { - [workspaceId]: `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] WIP: Add JWT authentication - ! [origin/main] Update dependencies --- -- [a1b2c3d] Add JWT authentication -- [e4f5g6h] Update auth middleware -- [i7j8k9l] Add tests ----DIRTY--- -3`, - [streamingWorkspaceId]: `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Refactoring database connection - ! [origin/main] Old implementation --- -- [b2c3d4e] Refactor connection pool -- [f5g6h7i] Add retry logic ----DIRTY--- -1`, - "ws-clean": `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Latest commit - ! [origin/main] Latest commit --- -++ [m1n2o3p] Latest commit ----DIRTY--- -0`, - "ws-ahead": `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Add new dashboard design - ! [origin/main] Old design --- -- [c3d4e5f] Add new dashboard design -- [g6h7i8j] Update styles ----DIRTY--- -0`, - "ws-behind": `---PRIMARY--- -main ----SHOW_BRANCH--- - ! [origin/main] Latest API changes -! [HEAD] Old API implementation --- - + [d4e5f6g] Latest API changes - + [h7i8j9k] Fix API bug ----DIRTY--- -0`, - "ws-dirty": `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Fix null pointer - ! [origin/main] Stable version --- -- [e5f6g7h] Fix null pointer ----DIRTY--- -7`, - "ws-diverged": `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Database migration - ! [origin/main] Old schema --- -- [f6g7h8i] Database migration -- [i9j0k1l] Update models - + [l2m3n4o] Hotfix on main ----DIRTY--- -5`, - "ws-ssh": `---PRIMARY--- -main ----SHOW_BRANCH--- -! [HEAD] Production deployment - ! [origin/main] Development version --- -- [g7h8i9j] Production deployment ----DIRTY--- -0`, - }; - - // Return mock git status if this is the git status script - if (command.includes("git status") || command.includes("git show-branch")) { - const output = gitStatusMocks[wsId] || ""; - return Promise.resolve({ - success: true, - data: { success: true, output, exitCode: 0, wall_duration_ms: 50 }, - }); - } - - // Default response for other commands - return Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }); - }, - }, - }, - }); - - // Set initial workspace selection - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - }) - ); - - // Pre-fill input with text so token count is visible - localStorage.setItem( - `input:${workspaceId}`, - "Add OAuth2 support with Google and GitHub providers" - ); - localStorage.setItem(`model:${workspaceId}`, "anthropic:claude-sonnet-4-5"); - - initialized.current = true; - } - - return ; - }; - - return ; - }, -}; - -/** - * Story demonstrating markdown table rendering - * Shows various table formats without disruptive copy/download actions - */ -export const MarkdownTables: Story = { - render: () => { - const AppWithTableMocks = () => { - const initialized = useRef(false); - - if (!initialized.current) { - const workspaceId = "my-app-feature"; - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: workspaceId, - name: "feature", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - ]; - - setupMockAPI({ - projects: new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "feature" }, - ], - }, - ], - ]), - workspaces, - selectedWorkspaceId: workspaceId, - apiOverrides: { - workspace: { - create: (projectPath: string, branchName: string) => - Promise.resolve({ - success: true, - metadata: { - id: Math.random().toString(36).substring(2, 12), - name: branchName, - projectPath, - projectName: projectPath.split("/").pop() ?? "project", - namedWorkspacePath: `/mock/workspace/${branchName}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - }), - list: () => Promise.resolve(workspaces), - rename: (workspaceId: string) => - Promise.resolve({ - success: true, - data: { newWorkspaceId: workspaceId }, - }), - remove: () => Promise.resolve({ success: true }), - fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), - openTerminal: () => Promise.resolve(undefined), - onChat: (workspaceId, callback) => { - setTimeout(() => { - // User message - callback({ - id: "msg-1", - role: "user", - parts: [{ type: "text", text: "Show me some table examples" }], - metadata: { - historySequence: 1, - timestamp: STABLE_TIMESTAMP, - }, - }); - - // Assistant message with tables - callback({ - id: "msg-2", - role: "assistant", - parts: [ - { - type: "text", - text: `Here are various markdown table examples: - -## Simple Table - -| Column 1 | Column 2 | Column 3 | -|----------|----------|----------| -| Value A | Value B | Value C | -| Value D | Value E | Value F | -| Value G | Value H | Value I | - -## Table with Different Alignments - -| Left Aligned | Center Aligned | Right Aligned | -|:-------------|:--------------:|--------------:| -| Left | Center | Right | -| Text | Text | Text | -| More | Data | Here | - -## Code and Links in Tables - -| Feature | Status | Notes | -|---------|--------|-------| -| \`markdown\` support | ✅ Done | Full GFM support | -| [Links](https://example.com) | ✅ Done | Opens externally | -| **Bold** and _italic_ | ✅ Done | Standard formatting | - -## Large Table with Many Rows - -| ID | Name | Email | Status | Role | Last Login | -|----|------|-------|--------|------|------------| -| 1 | Alice Smith | alice@example.com | Active | Admin | 2024-01-20 | -| 2 | Bob Jones | bob@example.com | Active | User | 2024-01-19 | -| 3 | Carol White | carol@example.com | Inactive | User | 2024-01-15 | -| 4 | David Brown | david@example.com | Active | Moderator | 2024-01-21 | -| 5 | Eve Wilson | eve@example.com | Active | User | 2024-01-18 | -| 6 | Frank Miller | frank@example.com | Pending | User | 2024-01-10 | -| 7 | Grace Lee | grace@example.com | Active | Admin | 2024-01-22 | -| 8 | Henry Davis | henry@example.com | Active | User | 2024-01-17 | - -## Narrow Table - -| # | Item | -|----|------| -| 1 | First | -| 2 | Second | -| 3 | Third | - -## Wide Table with Long Content - -| Configuration Key | Default Value | Description | Environment Variable | -|-------------------|---------------|-------------|---------------------| -| \`api.timeout\` | 30000 | Request timeout in milliseconds | \`API_TIMEOUT\` | -| \`cache.enabled\` | true | Enable response caching | \`CACHE_ENABLED\` | -| \`logging.level\` | info | Log verbosity level (debug, info, warn, error) | \`LOG_LEVEL\` | -| \`server.port\` | 3000 | Port number for HTTP server | \`PORT\` | - -These tables should render cleanly without any disruptive copy or download actions.`, - }, - ], - metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP + 1000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 100, - outputTokens: 500, - totalTokens: 600, - }, - duration: 2000, - }, - }); - - // Mark as caught up - callback({ type: "caught-up" }); - }, 100); - - return () => { - // Cleanup - }; - }, - onMetadata: () => () => undefined, - activity: { - list: () => Promise.resolve({}), - subscribe: () => () => undefined, - }, - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - clearQueue: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: () => - Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }), - }, - }, - }); - - // Set initial workspace selection - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - }) - ); - - initialized.current = true; - } - - return ; - }; - - return ; - }, -}; - -/** - * Story showing the auto-compaction warning when context usage is approaching the threshold. - * The warning appears above the chat input when usage is >= 60% (threshold 70% minus 10% warning advance). - * claude-sonnet-4-5 has max_input_tokens: 200,000, so we set usage to ~130,000 tokens (65%) to trigger warning. - */ -export const AutoCompactionWarning: Story = { - render: () => { - const workspaceId = "ws-high-usage"; - - const projects = new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "main" }, - ], - }, - ], - ]); - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: workspaceId, - name: "main", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - createdAt: new Date(NOW - 3600000).toISOString(), - }, - ]; - - const AppWithHighUsage: React.FC = () => { - const initialized = useRef(false); - if (!initialized.current) { - // Enable auto-compaction for this workspace (enabled per-workspace, threshold per-model) - localStorage.setItem(`autoCompaction:enabled:${workspaceId}`, "true"); - localStorage.setItem(`autoCompaction:threshold:claude-sonnet-4-5`, "70"); - - setupMockAPI({ - projects, - workspaces, - apiOverrides: { - tokenizer: { - countTokens: () => Promise.resolve(100), - countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 100)), - calculateStats: () => - Promise.resolve({ - consumers: [], - totalTokens: 0, - model: "claude-sonnet-4-5", - tokenizerName: "claude", - usageHistory: [], - }), - }, - providers: { - setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), - setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => - Promise.resolve( - {} as Record - ), - list: () => Promise.resolve(["anthropic"]), - }, - workspace: { - create: (projectPath: string, branchName: string) => - Promise.resolve({ - success: true, - metadata: { - id: Math.random().toString(36).substring(2, 12), - name: branchName, - projectPath, - projectName: projectPath.split("/").pop() ?? "project", - namedWorkspacePath: `/mock/workspace/${branchName}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - }), - list: () => Promise.resolve(workspaces), - rename: (wsId: string) => - Promise.resolve({ success: true, data: { newWorkspaceId: wsId } }), - remove: () => Promise.resolve({ success: true }), - fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), - openTerminal: () => Promise.resolve(undefined), - onChat: (wsId, callback) => { - if (wsId === workspaceId) { - setTimeout(() => { - // User message - callback({ - id: "msg-1", - role: "user", - parts: [{ type: "text", text: "Help me with this large codebase" }], - metadata: { - historySequence: 1, - timestamp: STABLE_TIMESTAMP - 60000, - }, - }); - - // Assistant message with HIGH usage to trigger compaction warning - // 130,000 tokens = 65% of 200,000 max, which is above 60% warning threshold - callback({ - id: "msg-2", - role: "assistant", - parts: [ - { - type: "text", - text: "I've analyzed the codebase. The context window is getting full - notice the compaction warning below!", - }, - ], - metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP, - model: "claude-sonnet-4-5", - usage: { - inputTokens: 125000, // High input to trigger warning - outputTokens: 5000, - totalTokens: 130000, - }, - duration: 5000, - }, - }); - - callback({ type: "caught-up" }); - }, 100); - } - return () => undefined; - }, - onMetadata: () => () => undefined, - activity: { - list: () => Promise.resolve({}), - subscribe: () => () => undefined, - }, - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - clearQueue: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: () => - Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }), - }, - }, - }); - - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - }) - ); - - initialized.current = true; - } - - return ; - }; - - return ; - }, -}; diff --git a/src/browser/components/CommandPalette.stories.tsx b/src/browser/components/CommandPalette.stories.tsx deleted file mode 100644 index b18680a329..0000000000 --- a/src/browser/components/CommandPalette.stories.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import { CommandPalette } from "./CommandPalette"; -import { CommandRegistryProvider } from "@/browser/contexts/CommandRegistryContext"; -import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; -import { useEffect } from "react"; -import { useCommandRegistry } from "@/browser/contexts/CommandRegistryContext"; - -// Mock command actions for the demo -const mockCommands: CommandAction[] = [ - { - id: "workspace.create", - title: "Create New Workspace", - subtitle: "Start a new workspace in this project", - section: "Workspaces", - keywords: ["new", "add", "workspace"], - shortcutHint: "⌘N", - run: () => action("command-executed")("workspace.create"), - }, - { - id: "workspace.switch", - title: "Switch Workspace", - subtitle: "Navigate to a different workspace", - section: "Workspaces", - keywords: ["change", "go to", "workspace"], - shortcutHint: "⌘P", - run: () => action("command-executed")("workspace.switch"), - }, - { - id: "workspace.delete", - title: "Delete Workspace", - subtitle: "Remove the current workspace", - section: "Workspaces", - keywords: ["remove", "delete", "workspace"], - run: () => action("command-executed")("workspace.delete"), - }, - { - id: "chat.clear", - title: "Clear Chat History", - subtitle: "Remove all messages from current chat", - section: "Chat", - keywords: ["clear", "delete", "history", "messages"], - run: () => action("command-executed")("chat.clear"), - }, - { - id: "chat.export", - title: "Export Chat", - subtitle: "Export conversation to file", - section: "Chat", - keywords: ["export", "save", "download"], - run: () => action("command-executed")("chat.export"), - }, - { - id: "mode.toggle", - title: "Toggle Mode", - subtitle: "Switch between Plan and Exec modes", - section: "Mode", - keywords: ["mode", "switch", "plan", "exec"], - shortcutHint: "⌘⇧M", - run: () => action("command-executed")("mode.toggle"), - }, - { - id: "thinking.cycle", - title: "Cycle Thinking Level", - subtitle: "Change AI thinking intensity", - section: "Settings", - keywords: ["thinking", "level", "cycle"], - shortcutHint: "⌘⇧T", - run: () => action("command-executed")("thinking.cycle"), - }, - { - id: "model.change", - title: "Change Model", - subtitle: "Select a different AI model", - section: "Settings", - keywords: ["model", "ai", "change", "switch"], - shortcutHint: "⌘⇧K", - run: () => action("command-executed")("model.change"), - }, - { - id: "project.add", - title: "Add Project", - subtitle: "Add a new project to sidebar", - section: "Project", - keywords: ["add", "new", "project"], - run: () => action("command-executed")("project.add"), - }, - { - id: "project.remove", - title: "Remove Project", - subtitle: "Remove project from sidebar", - section: "Project", - keywords: ["remove", "delete", "project"], - run: () => action("command-executed")("project.remove"), - }, - { - id: "help.keybinds", - title: "Show Keyboard Shortcuts", - subtitle: "View all available keybindings", - section: "Help", - keywords: ["help", "shortcuts", "keybinds", "keys"], - shortcutHint: "⌘/", - run: () => action("command-executed")("help.keybinds"), - }, - { - id: "help.docs", - title: "Open Documentation", - subtitle: "View mux documentation", - section: "Help", - keywords: ["help", "docs", "documentation"], - run: () => action("command-executed")("help.docs"), - }, -]; - -// Component that registers mock commands and opens the palette -const PaletteDemo: React.FC<{ autoOpen?: boolean }> = ({ autoOpen = true }) => { - const { registerSource, open } = useCommandRegistry(); - - useEffect(() => { - // Register mock command source - const unregister = registerSource(() => mockCommands); - - // Auto-open palette for demo - if (autoOpen) { - setTimeout(() => open(), 100); - } - - return unregister; - }, [registerSource, open, autoOpen]); - - return ( - <> - - ({ - providerNames: ["anthropic", "openai", "google"], - workspaceId: "demo-workspace", - })} - /> - - ); -}; - -const meta = { - title: "Components/CommandPalette", - component: CommandPalette, - parameters: { - layout: "fullscreen", - backgrounds: { - default: "dark", - values: [{ name: "dark", value: "#1e1e1e" }], - }, - controls: { - exclude: ["getSlashContext"], - }, - }, - tags: ["autodocs"], - decorators: [ - (Story) => ( - - - - ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( -
-
- Command Palette -
-
- The command palette is automatically opened for demonstration. Click the button below to - reopen it. -
-
- Two Modes: -
Default: Workspace switcher (only shows switching commands) -
•{" "} - - Type > - - : Command mode (shows all other commands) -
•{" "} - - Type / - - : Slash commands for chat input -
-
- • Use ↑↓ arrow keys to navigate, Enter to execute -
- • Press Escape to close -
• Commands organized into sections (Workspaces, Chat, Mode, Settings, Project, Help) -
- -
- ), -}; diff --git a/src/browser/components/ErrorMessage.stories.tsx b/src/browser/components/ErrorMessage.stories.tsx deleted file mode 100644 index 4bc1167631..0000000000 --- a/src/browser/components/ErrorMessage.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ErrorMessage } from "./ErrorMessage"; - -const meta = { - title: "Components/ErrorMessage", - component: ErrorMessage, - parameters: { - layout: "padded", - }, - tags: ["autodocs"], - argTypes: { - title: { - control: "text", - description: "Optional title for the error message", - }, - message: { - control: "text", - description: "Main error message", - }, - details: { - control: "text", - description: "Optional additional details", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - message: "Something went wrong", - }, -}; - -export const WithTitle: Story = { - args: { - title: "Configuration Error", - message: "Failed to load workspace configuration", - }, -}; - -export const WithDetails: Story = { - args: { - title: "API Error", - message: "Failed to fetch data from server", - details: "Error: Connection timeout after 30s\nStatus: 504 Gateway Timeout", - }, -}; - -export const LongMessage: Story = { - args: { - title: "Build Failed", - message: - "The build process failed with multiple errors. This is a very long error message that demonstrates how the component handles multi-line content and word wrapping. The component should wrap text appropriately and maintain readability even with lengthy error messages.", - details: - "Stack trace:\n at buildProject (src/build.ts:45)\n at main (src/index.ts:12)\n at process._tickCallback (internal/process/next_tick.js:68)", - }, -}; - -export const MessageOnly: Story = { - args: { - message: "File not found: /path/to/missing/file.txt", - }, -}; - -export const NetworkError: Story = { - args: { - title: "Network Request Failed", - message: "Unable to connect to the API server", - details: "ECONNREFUSED: Connection refused at localhost:3000", - }, -}; diff --git a/src/browser/components/GitStatusIndicatorView.stories.tsx b/src/browser/components/GitStatusIndicatorView.stories.tsx deleted file mode 100644 index 1561831cd9..0000000000 --- a/src/browser/components/GitStatusIndicatorView.stories.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, waitFor } from "storybook/test"; -import { GitStatusIndicatorView } from "./GitStatusIndicatorView"; -import type { GitCommit, GitBranchHeader } from "@/common/utils/git/parseGitLog"; -import { useState } from "react"; - -// Type for the wrapped component props (without interaction handlers) -type InteractiveProps = Omit< - React.ComponentProps, - | "showTooltip" - | "tooltipCoords" - | "onMouseEnter" - | "onMouseLeave" - | "onTooltipMouseEnter" - | "onTooltipMouseLeave" - | "onContainerRef" ->; - -const meta = { - title: "Components/GitStatusIndicatorView", - component: GitStatusIndicatorView, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// Mock data for different scenarios -const mockBranchHeaders: GitBranchHeader[] = [ - { branch: "HEAD", columnIndex: 0 }, - { branch: "origin/main", columnIndex: 1 }, - { branch: "origin/feature-branch", columnIndex: 2 }, -]; - -const mockCommits: GitCommit[] = [ - { - hash: "a1b2c3d", - date: "Jan 15 02:30 PM", - subject: "feat: Add new feature", - indicators: "***", - }, - { - hash: "e4f5g6h", - date: "Jan 15 01:45 PM", - subject: "fix: Resolve bug in handler", - indicators: "*+ ", - }, - { - hash: "i7j8k9l", - date: "Jan 15 11:20 AM", - subject: "refactor: Simplify logic", - indicators: " + ", - }, - { - hash: "m0n1o2p", - date: "Jan 14 04:15 PM", - subject: "docs: Update README", - indicators: " +", - }, -]; - -const mockDirtyFiles = [ - "M src/components/GitStatusIndicator.tsx", - "M src/components/GitStatusIndicatorView.tsx", - "A src/components/hooks/useGitBranchDetails.ts", - "?? src/components/GitStatusIndicatorView.stories.tsx", -]; - -const mockManyDirtyFiles = [ - ...mockDirtyFiles, - "M src/utils/git.ts", - "M src/types/workspace.ts", - "A src/hooks/useData.ts", - "A src/hooks/useDebounce.ts", - "M package.json", - "M tsconfig.json", - "?? temp-file-1.txt", - "?? temp-file-2.txt", - "?? temp-file-3.txt", - "?? temp-file-4.txt", - "?? temp-file-5.txt", - "?? temp-file-6.txt", - "?? temp-file-7.txt", - "?? temp-file-8.txt", - "?? temp-file-9.txt", - "?? temp-file-10.txt", - "?? temp-file-11.txt", - "?? temp-file-12.txt", - "?? temp-file-13.txt", - "?? temp-file-14.txt", - "?? temp-file-15.txt", - "?? temp-file-16.txt", - "?? temp-file-17.txt", -]; - -// Interactive wrapper component for stories with hover state -const InteractiveWrapper = (props: InteractiveProps) => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipCoords, setTooltipCoords] = useState({ top: 0, left: 0 }); - const [containerEl, setContainerEl] = useState(null); - - const handleMouseEnter = () => { - setShowTooltip(true); - if (containerEl) { - const rect = containerEl.getBoundingClientRect(); - if (props.tooltipPosition === "bottom") { - setTooltipCoords({ - top: rect.bottom + 8, - left: rect.left, - }); - } else { - setTooltipCoords({ - top: rect.top + rect.height / 2, - left: rect.right + 8, - }); - } - } - }; - - const handleTooltipMouseEnter = () => { - // No-op for Storybook demo - in real app, prevents tooltip from closing when hovering over it - }; - - return ( - setShowTooltip(false)} - onTooltipMouseEnter={handleTooltipMouseEnter} - onTooltipMouseLeave={() => setShowTooltip(false)} - onContainerRef={setContainerEl} - /> - ); -}; - -// Basic indicator states -export const AheadOnly: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 0, dirty: false }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; - -export const BehindOnly: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 0, behind: 5, dirty: false }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; - -export const AheadAndBehind: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 5, dirty: false }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, - play: async ({ canvasElement }) => { - // Find the git status indicator element - const indicator = canvasElement.querySelector(".git-status-wrapper") ?? canvasElement; - - // Hover over the indicator to show tooltip - await userEvent.hover(indicator); - - // Wait for tooltip to appear with git status info - await waitFor( - () => { - // The tooltip is rendered as a portal in document.body - const tooltip = document.querySelector("[data-git-tooltip]"); - if (!tooltip) { - // Tooltip might not have a data attribute, so find it by checking for git-related text - const tooltips = document.querySelectorAll("div"); - const gitTooltip = Array.from(tooltips).find( - (el) => - el.textContent?.includes("ahead") || - el.textContent?.includes("behind") || - el.textContent?.includes("HEAD") - ); - if (gitTooltip?.style.position === "fixed") { - void expect(gitTooltip).toBeInTheDocument(); - void expect(gitTooltip.textContent).toContain("HEAD"); - return; - } - } - // If we have a data attribute, use that - if (tooltip) { - void expect(tooltip).toBeInTheDocument(); - } - }, - { timeout: 3000 } - ); - }, -}; - -export const DirtyOnly: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 0, behind: 0, dirty: true }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -export const AllCombined: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 5, dirty: true }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -// Tooltip content states -export const LoadingState: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 0, dirty: false }, - tooltipPosition: "right", - branchHeaders: null, - commits: null, - dirtyFiles: null, - isLoading: true, - errorMessage: null, - }, -}; - -export const ErrorState: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 0, dirty: false }, - tooltipPosition: "right", - branchHeaders: null, - commits: null, - dirtyFiles: null, - isLoading: false, - errorMessage: "Branch info unavailable: git command failed", - }, -}; - -export const NoCommitsState: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 0, dirty: false }, - tooltipPosition: "right", - branchHeaders: null, - commits: [], - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; - -// Dirty files variations -export const WithDirtyFiles: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 2, behind: 1, dirty: true }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -export const WithTruncatedDirtyFiles: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 0, behind: 0, dirty: true }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockManyDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -// Tooltip position variations -export const TooltipPositionRight: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 5, dirty: true }, - tooltipPosition: "right", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -export const TooltipPositionBottom: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 3, behind: 5, dirty: true }, - tooltipPosition: "bottom", - branchHeaders: mockBranchHeaders, - commits: mockCommits, - dirtyFiles: mockDirtyFiles, - isLoading: false, - errorMessage: null, - }, -}; - -// Edge cases -export const HiddenState: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 0, behind: 0, dirty: false }, - tooltipPosition: "right", - branchHeaders: null, - commits: null, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; - -export const NullGitStatus: Story = { - render: (args) => , - args: { - gitStatus: null, - tooltipPosition: "right", - branchHeaders: null, - commits: null, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; - -// Minimal branch info (no headers) -export const WithoutBranchHeaders: Story = { - render: (args) => , - args: { - gitStatus: { ahead: 2, behind: 1, dirty: false }, - tooltipPosition: "right", - branchHeaders: null, - commits: mockCommits, - dirtyFiles: null, - isLoading: false, - errorMessage: null, - }, -}; diff --git a/src/browser/components/KebabMenu.stories.tsx b/src/browser/components/KebabMenu.stories.tsx deleted file mode 100644 index 39c1f6d1ef..0000000000 --- a/src/browser/components/KebabMenu.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { KebabMenu } from "./KebabMenu"; -import { action } from "storybook/actions"; - -const meta = { - title: "Components/KebabMenu", - component: KebabMenu, - parameters: { - layout: "padded", - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - items: [ - { label: "Edit", onClick: action("edit") }, - { label: "Duplicate", onClick: action("duplicate") }, - { label: "Delete", onClick: action("delete") }, - ], - }, -}; - -export const WithEmojis: Story = { - args: { - items: [ - { label: "Start Here", emoji: "🎯", onClick: action("start-here") }, - { label: "Show Text", onClick: action("show-text") }, - { label: "Show JSON", onClick: action("show-json") }, - ], - }, -}; - -export const WithActiveState: Story = { - args: { - items: [ - { label: "Show Markdown", onClick: action("show-markdown") }, - { label: "Show Text", onClick: action("show-text"), active: true }, - { label: "Show JSON", onClick: action("show-json") }, - ], - }, -}; - -export const WithDisabledItems: Story = { - args: { - items: [ - { label: "Edit", onClick: action("edit") }, - { label: "Delete", onClick: action("delete"), disabled: true }, - { label: "Archive", onClick: action("archive") }, - ], - }, -}; - -export const WithTooltips: Story = { - args: { - items: [ - { - label: "Start Here", - emoji: "🎯", - onClick: action("start-here"), - tooltip: "Replace all chat history with this message", - }, - { label: "Show Text", onClick: action("show-text"), tooltip: "View raw text" }, - { label: "Show JSON", onClick: action("show-json"), tooltip: "View message as JSON" }, - ], - }, -}; - -export const ManyItems: Story = { - args: { - items: [ - { label: "Copy", onClick: action("copy") }, - { label: "Edit", onClick: action("edit") }, - { label: "Duplicate", onClick: action("duplicate") }, - { label: "Archive", onClick: action("archive") }, - { label: "Share", onClick: action("share") }, - { label: "Export", onClick: action("export") }, - { label: "Delete", onClick: action("delete"), disabled: true }, - ], - }, -}; diff --git a/src/browser/components/Messages/AssistantMessage.stories.tsx b/src/browser/components/Messages/AssistantMessage.stories.tsx deleted file mode 100644 index 4259a209ed..0000000000 --- a/src/browser/components/Messages/AssistantMessage.stories.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AssistantMessage } from "./AssistantMessage"; -import type { DisplayedMessage } from "@/common/types/message"; -import { action } from "storybook/actions"; - -// Stable timestamp for visual testing (Apple demo time: Jan 24, 2024, 9:41 AM PST) -const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); - -const clipboardWriteText = (data: string) => { - action("copy-text")(data); - return Promise.resolve(); -}; - -const meta = { - title: "Messages/AssistantMessage", - component: AssistantMessage, - parameters: { - layout: "padded", - controls: { - exclude: ["clipboardWriteText", "className", "workspaceId"], - }, - }, - tags: ["autodocs"], - argTypes: { - message: { - control: { type: "object" }, - description: "Assistant message data", - }, - className: { - control: false, - description: "Optional CSS class", - }, - workspaceId: { - control: false, - description: "Optional workspace ID for Start Here button", - }, - }, - args: { - clipboardWriteText, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const createAssistantMessage = ( - content: string, - overrides?: Partial -): DisplayedMessage & { type: "assistant" } => ({ - type: "assistant", - id: "asst-msg-1", - historyId: "hist-2", - content, - historySequence: 2, - isStreaming: false, - isPartial: false, - isCompacted: false, - timestamp: STABLE_TIMESTAMP, - model: "anthropic:claude-sonnet-4-5", - ...overrides, -}); - -export const BasicMarkdown: Story = { - args: { - message: createAssistantMessage( - "Here's how to fix that issue:\n\n```typescript\nfunction calculate(x: number, y: number): number {\n return x + y;\n}\n```\n\nThe problem was missing type annotations." - ), - }, -}; - -export const WithCodeBlocks: Story = { - args: { - message: createAssistantMessage( - "I'll help you with that. Here are the changes:\n\n" + - "**File: `src/utils.ts`**\n\n" + - "```typescript\nexport function formatDate(date: Date): string {\n return date.toISOString().split('T')[0];\n}\n```\n\n" + - "**File: `src/App.tsx`**\n\n" + - "```tsx\nimport { formatDate } from './utils';\n\nconst today = formatDate(new Date());\n```" - ), - }, -}; - -export const WithLists: Story = { - args: { - message: createAssistantMessage( - "Here are the main differences:\n\n" + - "**Props:**\n" + - "- Simple to use\n" + - "- Good for small apps\n" + - "- Can lead to prop drilling\n\n" + - "**Context:**\n" + - "- Avoids prop drilling\n" + - "- Better for medium apps\n" + - "- Can cause unnecessary re-renders\n\n" + - "**State Libraries:**\n" + - "1. Redux - Most mature, verbose\n" + - "2. Zustand - Simpler, less boilerplate\n" + - "3. Jotai - Atomic approach" - ), - }, -}; - -export const WithTable: Story = { - args: { - message: createAssistantMessage( - "Here's a comparison:\n\n" + - "| Feature | React Context | Redux | Zustand |\n" + - "|---------|---------------|-------|----------|\n" + - "| Learning Curve | Easy | Hard | Easy |\n" + - "| Boilerplate | Low | High | Low |\n" + - "| DevTools | No | Yes | Yes |\n" + - "| Bundle Size | Built-in | ~10KB | ~1KB |" - ), - }, -}; - -export const Streaming: Story = { - args: { - message: createAssistantMessage("I'm analyzing your code and will provide feedback...", { - isStreaming: true, - }), - }, -}; - -export const StreamingEmpty: Story = { - args: { - message: createAssistantMessage("", { - isStreaming: true, - }), - }, -}; - -export const WithModel: Story = { - args: { - message: createAssistantMessage( - "This response uses a specific model that's displayed in the header." - ), - }, -}; - -export const DifferentModel: Story = { - args: { - message: createAssistantMessage("This uses a different model.", { - model: "openai:gpt-5-codex", - }), - }, -}; - -export const Compacted: Story = { - args: { - message: createAssistantMessage( - "This is a compacted message that was used as a starting point for the conversation.", - { - isCompacted: true, - } - ), - }, -}; - -export const CompactedWithWorkspace: Story = { - args: { - message: createAssistantMessage( - "Previous conversation context that was compacted.\n\n" + - "The user was working on implementing authentication.", - { - isCompacted: true, - } - ), - workspaceId: "test-workspace-1", - }, -}; - -export const Partial: Story = { - args: { - message: createAssistantMessage( - "This message was interrupted before completion due to user stopping the generation...", - { - isPartial: true, - } - ), - }, -}; - -export const WithWorkspaceId: Story = { - args: { - message: createAssistantMessage( - "When a workspace ID is provided, the message shows a 'Start Here' button that allows " + - "compacting the chat history to this point." - ), - workspaceId: "test-workspace-1", - }, -}; - -export const LongResponse: Story = { - args: { - message: createAssistantMessage( - "# Comprehensive Guide to State Management\n\n" + - "## Introduction\n\n" + - "State management is a crucial aspect of modern web applications. " + - "As applications grow in complexity, managing state becomes increasingly important.\n\n" + - "## Options\n\n" + - "### 1. Local State\n\n" + - "Local state using `useState` is perfect for component-specific data:\n\n" + - "```typescript\nconst [count, setCount] = useState(0);\n```\n\n" + - "### 2. Context API\n\n" + - "React Context provides a way to share state across components:\n\n" + - "```typescript\nconst ThemeContext = createContext('light');\n```\n\n" + - "### 3. Redux\n\n" + - "Redux offers predictable state management with actions and reducers:\n\n" + - "```typescript\nconst store = createStore(reducer);\n```\n\n" + - "## Best Practices\n\n" + - "1. Keep state as local as possible\n" + - "2. Use Context for truly global state\n" + - "3. Consider Redux for complex applications\n" + - "4. Normalize your state shape\n\n" + - "## Conclusion\n\n" + - "Choose the right tool for your use case. Start simple and add complexity only when needed." - ), - }, -}; - -export const WithMermaidDiagram: Story = { - args: { - message: createAssistantMessage( - "Here's the architecture:\n\n" + - "```mermaid\ngraph TD\n" + - " A[User] --> B[Frontend]\n" + - " B --> C[API]\n" + - " C --> D[Database]\n" + - " C --> E[Cache]\n" + - "```\n\n" + - "This shows the data flow through the system." - ), - }, -}; - -export const EmptyContent: Story = { - args: { - message: createAssistantMessage(""), - }, -}; - -export const LongModelName: Story = { - args: { - message: createAssistantMessage( - "This message has a very long model name that should be truncated to show the end.", - { - model: "anthropic:claude-opus-4-20250514-preview-experimental", - } - ), - }, -}; - -export const WithKebabMenu: Story = { - args: { - message: createAssistantMessage( - "The header now uses a kebab menu (⋮) to reduce clutter. " + - "Click the three dots to see actions like 'Show Text' and 'Show JSON'. " + - "The 'Copy Text' button remains visible for quick access." - ), - }, -}; diff --git a/src/browser/components/Messages/HistoryHiddenMessage.stories.tsx b/src/browser/components/Messages/HistoryHiddenMessage.stories.tsx deleted file mode 100644 index 15967a0602..0000000000 --- a/src/browser/components/Messages/HistoryHiddenMessage.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { HistoryHiddenMessage } from "./HistoryHiddenMessage"; -import type { DisplayedMessage } from "@/common/types/message"; - -const meta = { - title: "Messages/HistoryHiddenMessage", - component: HistoryHiddenMessage, - parameters: { - layout: "padded", - controls: { - exclude: ["className"], - }, - }, - tags: ["autodocs"], - argTypes: { - message: { - control: "object", - description: "History hidden indicator message", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const createHistoryHiddenMessage = ( - hiddenCount: number -): DisplayedMessage & { type: "history-hidden" } => ({ - type: "history-hidden", - id: `hidden-msg-${hiddenCount}`, - hiddenCount, - historySequence: 1, -}); - -export const SingleMessage: Story = { - args: { - message: createHistoryHiddenMessage(1), - }, -}; - -export const FewMessages: Story = { - args: { - message: createHistoryHiddenMessage(5), - }, -}; - -export const ManyMessages: Story = { - args: { - message: createHistoryHiddenMessage(42), - }, -}; - -export const HundredsOfMessages: Story = { - args: { - message: createHistoryHiddenMessage(234), - }, -}; - -export const ThousandsOfMessages: Story = { - args: { - message: createHistoryHiddenMessage(1567), - }, -}; diff --git a/src/browser/components/Messages/ReasoningMessage.stories.tsx b/src/browser/components/Messages/ReasoningMessage.stories.tsx deleted file mode 100644 index 0c7d50c6af..0000000000 --- a/src/browser/components/Messages/ReasoningMessage.stories.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ReasoningMessage } from "./ReasoningMessage"; -import type { DisplayedMessage } from "@/common/types/message"; - -// Stable timestamp for visual testing (Apple demo time: Jan 24, 2024, 9:41 AM PST) -const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); - -const meta = { - title: "Messages/ReasoningMessage", - component: ReasoningMessage, - parameters: { - layout: "padded", - controls: { - exclude: ["className"], - }, - }, - tags: ["autodocs"], - argTypes: { - message: { - control: "object", - description: "Reasoning message data", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const createReasoningMessage = ( - content: string, - overrides?: Partial -): DisplayedMessage & { type: "reasoning" } => ({ - type: "reasoning", - id: "reasoning-msg-1", - historyId: "hist-reasoning-1", - content, - historySequence: 1, - isStreaming: false, - isPartial: false, - timestamp: STABLE_TIMESTAMP, - ...overrides, -}); - -export const Basic: Story = { - args: { - message: createReasoningMessage( - "I need to analyze the code structure first to understand the dependencies before making changes.", - { isStreaming: true } - ), - }, -}; - -export const MultiParagraph: Story = { - args: { - message: createReasoningMessage( - "First, I'll check the current implementation to see how the state is managed.\n\n" + - "Then I'll identify the specific areas where the bug might be occurring.\n\n" + - "Finally, I'll propose a solution that maintains backward compatibility.", - { isStreaming: true } - ), - }, -}; - -export const Empty: Story = { - args: { - message: createReasoningMessage("", { - isStreaming: true, - }), - }, -}; - -export const LongReasoning: Story = { - args: { - message: createReasoningMessage( - "Looking at this problem, I need to consider several factors:\n\n" + - "1. The current architecture uses a centralized state management approach, " + - "which means any changes need to be carefully coordinated.\n\n" + - "2. The component hierarchy suggests that prop drilling might be an issue, " + - "so I should consider using Context or a state library.\n\n" + - "3. Performance is a concern here since we're dealing with frequent updates. " + - "I'll need to ensure we're not causing unnecessary re-renders.\n\n" + - "4. The existing tests assume a certain behavior, so I need to verify " + - "that my changes won't break the test suite.\n\n" + - "Based on these considerations, I think the best approach is to...", - { isStreaming: true } - ), - }, -}; - -export const WithCodeAnalysis: Story = { - args: { - message: createReasoningMessage( - "The function signature indicates this is expecting a callback, but the current " + - "implementation passes a promise. This mismatch is likely causing the error.\n\n" + - "I should refactor the code to either:\n" + - "- Wrap the promise in a callback, or\n" + - "- Update the function to accept promises directly", - { isStreaming: true } - ), - }, -}; - -export const PlanningSteps: Story = { - args: { - message: createReasoningMessage( - "To solve this, I'll follow these steps:\n\n" + - "**Step 1:** Identify the root cause by examining the error stack trace\n\n" + - "**Step 2:** Review the related code to understand the context\n\n" + - "**Step 3:** Implement a fix that addresses the core issue\n\n" + - "**Step 4:** Add tests to prevent regression\n\n" + - "Let me start with Step 1...", - { isStreaming: true } - ), - }, -}; - -export const DecisionMaking: Story = { - args: { - message: createReasoningMessage( - "I'm considering two approaches:\n\n" + - "**Option A:** Refactor the entire component\n" + - "- Pros: Clean solution, better maintainability\n" + - "- Cons: Higher risk, more changes\n\n" + - "**Option B:** Minimal patch to fix the immediate issue\n" + - "- Pros: Lower risk, quick fix\n" + - "- Cons: Technical debt, may need revisiting\n\n" + - "Given the time constraints and risk profile, I recommend Option B for now.", - { isStreaming: true } - ), - }, -}; - -export const EmptyContent: Story = { - args: { - message: createReasoningMessage(""), - }, -}; -export const ExpandablePreview: Story = { - args: { - message: createReasoningMessage( - "Assessing quicksort mechanics and choosing example array...\n" + - "Plan: explain pivot selection, partitioning, recursion, base case.\n" + - "Next, I'll outline best practices for implementing the partition step.", - { isStreaming: false } - ), - }, -}; diff --git a/src/browser/components/Messages/StreamErrorMessage.stories.tsx b/src/browser/components/Messages/StreamErrorMessage.stories.tsx deleted file mode 100644 index 48c3e6eaef..0000000000 --- a/src/browser/components/Messages/StreamErrorMessage.stories.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { StreamErrorMessage } from "./StreamErrorMessage"; -import type { DisplayedMessage } from "@/common/types/message"; -import type { StreamErrorType } from "@/common/types/errors"; - -// Stable timestamp for visual testing (Apple demo time: Jan 24, 2024, 9:41 AM PST) -const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); - -const meta = { - title: "Messages/StreamErrorMessage", - component: StreamErrorMessage, - parameters: { - layout: "padded", - controls: { - exclude: ["className"], - }, - }, - tags: ["autodocs"], - argTypes: { - message: { - control: "object", - description: "Stream error message data", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const createStreamErrorMessage = ( - error: string, - errorType: StreamErrorType, - overrides?: Partial -): DisplayedMessage & { type: "stream-error" } => ({ - type: "stream-error", - id: "error-msg-1", - historyId: "hist-error-1", - error, - errorType, - historySequence: 1, - timestamp: STABLE_TIMESTAMP, - ...overrides, -}); - -export const NetworkError: Story = { - args: { - message: createStreamErrorMessage( - "Failed to connect to the API server. Please check your network connection.", - "network" - ), - }, -}; - -export const RateLimitError: Story = { - args: { - message: createStreamErrorMessage( - "Rate limit exceeded. Please wait before making another request.", - "rate_limit" - ), - }, -}; - -export const AuthenticationError: Story = { - args: { - message: createStreamErrorMessage( - "Invalid API key. Please check your configuration and try again.", - "authentication" - ), - }, -}; - -export const TimeoutError: Story = { - args: { - message: createStreamErrorMessage( - "Request timed out after 30 seconds. The server may be experiencing high load.", - "unknown" - ), - }, -}; - -export const ServerError: Story = { - args: { - message: createStreamErrorMessage( - "Internal server error (500). The service is temporarily unavailable.", - "server_error" - ), - }, -}; - -export const ValidationError: Story = { - args: { - message: createStreamErrorMessage( - "Invalid request: message content is empty or malformed.", - "api" - ), - }, -}; - -export const WithErrorCount: Story = { - args: { - message: createStreamErrorMessage( - "Connection refused. The server is not responding.", - "network", - { - errorCount: 3, - } - ), - }, -}; - -export const HighErrorCount: Story = { - args: { - message: createStreamErrorMessage("Request failed due to network instability.", "network", { - errorCount: 15, - }), - }, -}; - -export const LongErrorMessage: Story = { - args: { - message: createStreamErrorMessage( - "An unexpected error occurred while processing your request. " + - "The error details are: Error: ECONNREFUSED - Connection refused at TCPConnectWrap.afterConnect " + - "(net.js:1148:16). This typically indicates that the server is not running or is not accessible " + - "from your current network location. Please verify that the API endpoint is correct and that " + - "you have proper network connectivity.", - "network" - ), - }, -}; - -export const WithStackTrace: Story = { - args: { - message: createStreamErrorMessage( - "TypeError: Cannot read property 'content' of undefined\n" + - " at processMessage (stream.js:245:32)\n" + - " at handleChunk (stream.js:189:18)\n" + - " at Stream. (stream.js:142:9)\n" + - " at Stream.emit (events.js:315:20)", - "unknown" - ), - }, -}; - -export const GenericError: Story = { - args: { - message: createStreamErrorMessage("An unknown error occurred.", "unknown"), - }, -}; diff --git a/src/browser/components/Messages/TerminalOutput.stories.tsx b/src/browser/components/Messages/TerminalOutput.stories.tsx deleted file mode 100644 index 129c76974c..0000000000 --- a/src/browser/components/Messages/TerminalOutput.stories.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { TerminalOutput } from "./TerminalOutput"; - -const meta = { - title: "Messages/TerminalOutput", - component: TerminalOutput, - parameters: { - layout: "padded", - backgrounds: { - default: "dark", - values: [{ name: "dark", value: "#1e1e1e" }], - }, - controls: { - exclude: ["className"], - }, - }, - tags: ["autodocs"], - argTypes: { - output: { - control: "text", - description: "Terminal output text", - }, - isError: { - control: "boolean", - description: "Whether the output represents an error", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const SuccessOutput: Story = { - args: { - output: - "$ npm install\nadded 324 packages in 4.2s\n\n42 packages are looking for funding\n run `npm fund` for details", - isError: false, - }, -}; - -export const ErrorOutput: Story = { - args: { - output: - "Error: Command failed with exit code 1\n at ChildProcess.exithandler (node:child_process:419:12)\n at ChildProcess.emit (node:events:513:28)", - isError: true, - }, -}; - -export const LongOutput: Story = { - args: { - output: `Running tests... - -✓ components/Tooltip.test.tsx (12 tests) -✓ components/StatusIndicator.test.tsx (8 tests) -✓ components/ToggleGroup.test.tsx (6 tests) -✓ components/Modal.test.tsx (10 tests) -✓ utils/format.test.ts (15 tests) -✓ utils/validation.test.ts (20 tests) -✓ hooks/useThinkingLevel.test.ts (5 tests) -✓ hooks/useWorkspace.test.ts (12 tests) - -Test Files 8 passed (8) - Tests 88 passed (88) - Start at 12:34:56 - Duration 2.45s (transform 123ms, setup 0ms, collect 456ms, tests 1.87s)`, - isError: false, - }, -}; - -export const CompilationError: Story = { - args: { - output: `src/components/Example.tsx:42:15 - TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. - 40 | const result = calculate(value); - 41 | if (result > 0) { - > 42 | return format(result.toString()); - | ^^^^^^^^^^^^^^^^^^^^^^^ - 43 | } - 44 | return null;`, - isError: true, - }, -}; - -export const GitOutput: Story = { - args: { - output: `$ git status -On branch feature/new-component -Your branch is up to date with 'origin/feature/new-component'. - -Changes not staged for commit: - (use "git add ..." to update what will be committed) - (use "git restore ..." to discard changes in working directory) - modified: src/components/NewComponent.tsx - modified: src/components/NewComponent.stories.tsx - -Untracked files: - (use "git add ..." to include in what will be committed) - src/components/AnotherComponent.tsx - -no changes added to commit (use "git add" and/or "git commit -a")`, - isError: false, - }, -}; - -export const WithANSI: Story = { - args: { - output: `[32m✓[0m All tests passed -[33m⚠[0m 2 warnings detected -[31m✗[0m 1 deprecation warning - -Run time: [36m2.45s[0m`, - isError: false, - }, -}; - -export const EmptyOutput: Story = { - args: { - output: "", - isError: false, - }, -}; - -export const SingleLine: Story = { - args: { - output: "Server started successfully on port 3000", - isError: false, - }, -}; diff --git a/src/browser/components/Messages/UserMessage.stories.tsx b/src/browser/components/Messages/UserMessage.stories.tsx deleted file mode 100644 index 105268c7fe..0000000000 --- a/src/browser/components/Messages/UserMessage.stories.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import { UserMessage } from "./UserMessage"; -import type { DisplayedMessage } from "@/common/types/message"; - -// Stable timestamp for visual testing (Apple demo time: Jan 24, 2024, 9:41 AM PST) -const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); - -const clipboardWriteText = (data: string) => { - action("copy-text")(data); - return Promise.resolve(); -}; - -const meta = { - title: "Messages/UserMessage", - component: UserMessage, - parameters: { - layout: "padded", - controls: { - exclude: ["onEdit", "className", "clipboardWriteText"], - }, - }, - tags: ["autodocs"], - argTypes: { - message: { - control: "object", - description: "User message data", - }, - }, - args: { - onEdit: action("onEdit"), - clipboardWriteText, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const createUserMessage = ( - content: string, - overrides?: Partial -): DisplayedMessage & { type: "user" } => ({ - type: "user", - id: "user-msg-1", - historyId: "hist-1", - content, - historySequence: 1, - timestamp: STABLE_TIMESTAMP, - ...overrides, -}); - -export const BasicText: Story = { - args: { - message: createUserMessage("Can you help me debug this function?"), - }, -}; - -export const MultilineText: Story = { - args: { - message: createUserMessage( - "Here's my code:\n\nfunction calculate(x, y) {\n return x + y;\n}\n\nWhat's wrong with it?" - ), - }, -}; - -export const WithEditHandler: Story = { - args: { - message: createUserMessage("I need to edit this message"), - }, -}; - -export const WithSingleImage: Story = { - args: { - message: createUserMessage("What's in this image?", { - imageParts: [ - { - url: "https://placehold.co/600x400", - mediaType: "image/png", - }, - ], - }), - }, -}; - -export const WithMultipleImages: Story = { - args: { - message: createUserMessage("Compare these screenshots:", { - imageParts: [ - { - url: "https://placehold.co/600x400?text=Before", - mediaType: "image/png", - }, - { - url: "https://placehold.co/600x400?text=After", - mediaType: "image/png", - }, - { - url: "https://placehold.co/600x400?text=Expected", - mediaType: "image/png", - }, - ], - }), - }, -}; - -export const LongText: Story = { - args: { - message: createUserMessage( - "I'm working on a complex problem that requires a detailed explanation. " + - "The issue involves multiple components interacting with each other, and I need to understand " + - "how to properly structure the data flow between them. Specifically, I'm dealing with state " + - "management in a React application where I have parent components passing props down to children, " + - "but I also need some children to communicate back up to parents. Should I use callbacks, " + - "context, or a state management library like Redux or Zustand? What are the tradeoffs?" - ), - }, -}; - -export const EmptyContent: Story = { - args: { - message: createUserMessage("", { - imageParts: [ - { - url: "https://placehold.co/300x400?text=Image+Only", - mediaType: "image/png", - }, - ], - }), - }, -}; - -export const WithKebabMenu: Story = { - args: { - message: createUserMessage( - "User messages now have a kebab menu (⋮) with the Edit action hidden inside. Click the three dots to access it." - ), - onEdit: action("onEdit"), - }, -}; diff --git a/src/browser/components/Modal.stories.tsx b/src/browser/components/Modal.stories.tsx deleted file mode 100644 index acaf82af21..0000000000 --- a/src/browser/components/Modal.stories.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import { expect, userEvent, waitFor } from "storybook/test"; -import { useState } from "react"; -import { - Modal, - ModalActions, - PrimaryButton, - CancelButton, - DangerButton, - ModalInfo, - WarningBox, - WarningTitle, - WarningText, -} from "./Modal"; - -const meta = { - title: "Components/Modal", - component: Modal, - parameters: { - layout: "fullscreen", - controls: { - exclude: ["onClose"], - }, - }, - tags: ["autodocs"], - argTypes: { - title: { - control: "text", - description: "Modal title", - }, - subtitle: { - control: "text", - description: "Optional subtitle", - }, - isOpen: { - control: "boolean", - description: "Whether the modal is visible", - }, - isLoading: { - control: "boolean", - description: "Disables closing when true", - }, - maxWidth: { - control: "text", - description: "Maximum width (e.g., '500px', '800px')", - }, - maxHeight: { - control: "text", - description: "Maximum height (e.g., '400px', '80vh')", - }, - children: { - control: false, // Disable controls for children to avoid serialization issues - }, - onClose: { - control: false, - action: "onClose", - }, - }, - args: { - onClose: () => { - // No-op for Storybook - in real app this closes the modal - }, - isOpen: true, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - args: { - title: "Basic Modal", - children: ( - <> -

This is a basic modal with some content.

- - Cancel - Confirm - - - ), - }, -}; - -export const WithSubtitle: Story = { - args: { - title: "Create New Workspace", - subtitle: "Enter a name for your new workspace", - children: ( - <> -

This modal includes a subtitle below the title.

- - Cancel - OK - - - ), - }, -}; - -export const WithInfoBox: Story = { - args: { - title: "Confirm Action", - children: ( - <> - -

- This operation will create a new workspace at ~/mux/project/branch -

-

Existing files will not be affected.

-
- - Cancel - Create - - - ), - }, -}; - -export const WithWarning: Story = { - args: { - title: "Delete Workspace", - subtitle: "Are you sure?", - children: ( - <> - - Warning - - This action cannot be undone. All data will be permanently deleted. - - - - Cancel - Delete - - - ), - }, -}; - -export const Loading: Story = { - args: { - title: "Please Wait", - isLoading: true, - children: ( - <> -

Processing your request...

- - Cancel - Confirm - - - ), - }, -}; - -export const WideModal: Story = { - args: { - title: "Wide Modal Example", - maxWidth: "800px", - children: ( - <> -

This modal has a wider maximum width to accommodate more content.

- -

- You can customize the modal width by passing the maxWidth prop. -

-
- - Close - - - ), - }, -}; - -// Interactive test stories -export const EscapeKeyCloses: Story = { - args: { - title: "Press Escape to Close", - children: ( - <> -

Try pressing the Escape key to close this modal.

- - Cancel - OK - - - ), - }, - render: function Render(args) { - const [isOpen, setIsOpen] = useState(true); - - return ( - <> - {!isOpen &&
Modal was closed! ✓
} - setIsOpen(false)} /> - - ); - }, - play: async () => { - // Modal is initially open - const modal = document.querySelector('[role="dialog"]'); - await expect(modal).toBeInTheDocument(); - - // Wait for modal to be fully mounted and event listeners attached - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Press Escape key - await userEvent.keyboard("{Escape}"); - - // Wait for modal to be removed from DOM - await waitFor(async () => { - const closedModal = document.querySelector('[role="dialog"]'); - await expect(closedModal).not.toBeInTheDocument(); - }); - }, -}; - -export const OverlayClickCloses: Story = { - args: { - title: "Click Overlay to Close", - children: ( - <> -

Click outside this modal (on the dark overlay) to close it.

- - Cancel - - - ), - }, - render: function Render(args) { - const [isOpen, setIsOpen] = useState(true); - - return ( - <> - {!isOpen &&
Modal was closed! ✓
} - setIsOpen(false)} /> - - ); - }, - play: async () => { - // Modal is initially open - const modal = document.querySelector('[role="dialog"]'); - await expect(modal).toBeInTheDocument(); - - // Wait for modal to be fully mounted and event listeners attached - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Click on overlay (role="presentation") - const overlay = document.querySelector('[role="presentation"]'); - await expect(overlay).toBeInTheDocument(); - await userEvent.click(overlay!); - - // Wait for modal to be removed from DOM - await waitFor(async () => { - const closedModal = document.querySelector('[role="dialog"]'); - await expect(closedModal).not.toBeInTheDocument(); - }); - }, -}; - -export const ContentClickDoesNotClose: Story = { - args: { - title: "Click Inside Modal", - children: ( - <> -

Clicking inside the modal content should not close it.

- - Cancel - OK - - - ), - }, - render: function Render(args) { - const [isOpen, setIsOpen] = useState(true); - - return ( - <> - {!isOpen &&
Modal was closed!
} - setIsOpen(false)} /> - - ); - }, - play: async () => { - // Modal is initially open - const modal = document.querySelector('[role="dialog"]'); - await expect(modal).toBeInTheDocument(); - - // Click on the modal content itself - await userEvent.click(modal!); - - // Give time for any potential state change - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Modal should still be open - const stillOpenModal = document.querySelector('[role="dialog"]'); - await expect(stillOpenModal).toBeInTheDocument(); - }, -}; - -export const LoadingPreventsClose: Story = { - args: { - title: "Loading State", - isLoading: true, - children: ( - <> -

This modal cannot be closed while loading.

- - Cancel - Processing... - - - ), - }, - render: function Render(args) { - const [isOpen, setIsOpen] = useState(true); - - return setIsOpen(false)} />; - }, - play: async () => { - // Modal is initially open - const modal = document.querySelector('[role="dialog"]'); - await expect(modal).toBeInTheDocument(); - - // Try to press Escape (should not work due to isLoading=true) - await userEvent.keyboard("{Escape}"); - - // Give time for any potential state change - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Modal should still be open - const stillOpenModal1 = document.querySelector('[role="dialog"]'); - await expect(stillOpenModal1).toBeInTheDocument(); - - // Try to click overlay (should also not work) - const overlay = document.querySelector('[role="presentation"]'); - await userEvent.click(overlay!); - - // Give time for any potential state change - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Modal should still be open - const stillOpenModal2 = document.querySelector('[role="dialog"]'); - await expect(stillOpenModal2).toBeInTheDocument(); - }, -}; diff --git a/src/browser/components/ModelSelector.stories.tsx b/src/browser/components/ModelSelector.stories.tsx deleted file mode 100644 index 43736d3193..0000000000 --- a/src/browser/components/ModelSelector.stories.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ModelSelector } from "./ModelSelector"; -import { action } from "storybook/actions"; -import { SettingsProvider } from "@/browser/contexts/SettingsContext"; - -const meta = { - title: "Components/ModelSelector", - component: ModelSelector, - parameters: { - layout: "padded", - }, - tags: ["autodocs"], - decorators: [ - (Story) => ( - - - - ), - ], - argTypes: { - value: { - control: { type: "text" }, - description: "Current model value", - }, - onChange: { - control: false, - description: "Callback when model changes", - }, - recentModels: { - control: { type: "object" }, - description: "List of recently used models", - }, - onComplete: { - control: false, - description: "Callback when selection is complete", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - value: "anthropic:claude-sonnet-4-5", - onChange: action("onChange"), - recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"], - onComplete: action("onComplete"), - }, -}; - -export const LongModelName: Story = { - args: { - value: "anthropic:claude-opus-4-20250514-preview-experimental", - onChange: action("onChange"), - recentModels: [ - "anthropic:claude-opus-4-20250514-preview-experimental", - "anthropic:claude-sonnet-4-20250514-preview-experimental", - "openai:gpt-5-preview-2025-01-15-experimental", - ], - onComplete: action("onComplete"), - }, -}; - -export const WithManyModels: Story = { - args: { - value: "anthropic:claude-sonnet-4-5", - onChange: action("onChange"), - recentModels: [ - "anthropic:claude-sonnet-4-5", - "anthropic:claude-opus-4-1", - "anthropic:claude-haiku-4-0", - "openai:gpt-5-pro", - "openai:gpt-5-mini", - "openai:gpt-4o", - "openai:gpt-4o-mini", - ], - onComplete: action("onComplete"), - }, -}; - -export const WithDefaultModel: Story = { - args: { - value: "anthropic:claude-sonnet-4-5", - onChange: action("onChange"), - recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"], - onComplete: action("onComplete"), - defaultModel: "anthropic:claude-opus-4-1", - onSetDefaultModel: (model) => { - // Mimic the hook behavior - only allow setting, not clearing - if (model) action("onSetDefaultModel")(model); - }, - }, -}; diff --git a/src/browser/components/ProjectCreateModal.stories.tsx b/src/browser/components/ProjectCreateModal.stories.tsx deleted file mode 100644 index 5b86bf745d..0000000000 --- a/src/browser/components/ProjectCreateModal.stories.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import { expect, userEvent, waitFor, within } from "storybook/test"; -import { useState } from "react"; -import { ProjectCreateModal } from "./ProjectCreateModal"; -import type { IPCApi } from "@/common/types/ipc"; -import type { FileTreeNode } from "@/common/utils/git/numstatParser"; - -// Mock file tree structure for directory picker -const mockFileTree: FileTreeNode = { - name: "home", - path: "/home", - isDirectory: true, - children: [ - { - name: "user", - path: "/home/user", - isDirectory: true, - children: [ - { - name: "projects", - path: "/home/user/projects", - isDirectory: true, - children: [ - { - name: "my-app", - path: "/home/user/projects/my-app", - isDirectory: true, - children: [], - }, - { - name: "api-server", - path: "/home/user/projects/api-server", - isDirectory: true, - children: [], - }, - ], - }, - { - name: "documents", - path: "/home/user/documents", - isDirectory: true, - children: [], - }, - ], - }, - ], -}; - -// Find a node in the mock tree by path -function findNodeByPath(root: FileTreeNode, targetPath: string): FileTreeNode | null { - // Normalize paths for comparison - const normTarget = targetPath.replace(/\/\.\.$/, ""); // Handle parent nav - if (targetPath.endsWith("/..")) { - // Navigate to parent - const parts = normTarget.split("/").filter(Boolean); - parts.pop(); - const parentPath = "/" + parts.join("/"); - return findNodeByPath(root, parentPath || "/"); - } - - if (root.path === targetPath) return root; - for (const child of root.children) { - const found = findNodeByPath(child, targetPath); - if (found) return found; - } - return null; -} - -// Setup mock API with fs.listDirectory support (browser mode) -function setupMockAPI(options?: { onProjectCreate?: (path: string) => void }) { - const mockApi: Partial & { platform: string } = { - platform: "browser", // Enable web directory picker - fs: { - listDirectory: async (path: string) => { - // Simulate async delay - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Handle "." as starting path - const targetPath = path === "." ? "/home/user" : path; - const node = findNodeByPath(mockFileTree, targetPath); - - if (!node) { - return { - success: false, - error: `Directory not found: ${path}`, - } as unknown as FileTreeNode; - } - return node; - }, - }, - projects: { - list: () => Promise.resolve([]), - create: (path: string) => { - options?.onProjectCreate?.(path); - return Promise.resolve({ - success: true, - data: { - normalizedPath: path, - projectConfig: { workspaces: [] }, - }, - }); - }, - remove: () => Promise.resolve({ success: true, data: undefined }), - pickDirectory: () => Promise.resolve(null), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), - secrets: { - get: () => Promise.resolve([]), - update: () => Promise.resolve({ success: true, data: undefined }), - }, - }, - }; - - // @ts-expect-error - Assigning partial mock API to window for Storybook - window.api = mockApi; -} - -const meta = { - title: "Components/ProjectCreateModal", - component: ProjectCreateModal, - parameters: { - layout: "fullscreen", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - setupMockAPI(); - return ; - }, - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// Wrapper component for interactive stories -const ProjectCreateModalWrapper: React.FC<{ - onSuccess?: (path: string) => void; - startOpen?: boolean; -}> = ({ onSuccess, startOpen = true }) => { - const [isOpen, setIsOpen] = useState(startOpen); - - return ( - <> - {!isOpen && ( - - )} - setIsOpen(false)} - onSuccess={(path, config) => { - action("project-created")({ path, config }); - onSuccess?.(path); - setIsOpen(false); - }} - /> - - ); -}; - -export const Default: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, -}; - -export const WithTypedPath: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal to be visible - await waitFor(() => { - expect(canvas.getByRole("dialog")).toBeInTheDocument(); - }); - - // Find and type in the input field - const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - await userEvent.type(input, "/home/user/projects/my-app"); - - // Verify input value - expect(input).toHaveValue("/home/user/projects/my-app"); - }, -}; - -export const BrowseButtonOpensDirectoryPicker: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal to be visible - await waitFor(() => { - expect(canvas.getByRole("dialog")).toBeInTheDocument(); - }); - - // Find and click the Browse button - const browseButton = canvas.getByText("Browse…"); - expect(browseButton).toBeInTheDocument(); - await userEvent.click(browseButton); - - // Wait for DirectoryPickerModal to open (it has title "Select Project Directory") - await waitFor(() => { - expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); - }); - }, -}; - -export const DirectoryPickerNavigation: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal and click Browse - await waitFor(() => { - expect(canvas.getByRole("dialog")).toBeInTheDocument(); - }); - - await userEvent.click(canvas.getByText("Browse…")); - - // Wait for DirectoryPickerModal to open and load directories - await waitFor(() => { - expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); - }); - - // Wait for directory listing to load (should show subdirectories of /home/user) - await waitFor( - () => { - expect(canvas.getByText("projects")).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - - // Navigate into "projects" directory - await userEvent.click(canvas.getByText("projects")); - - // Wait for subdirectories to load - await waitFor( - () => { - expect(canvas.getByText("my-app")).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - }, -}; - -export const DirectoryPickerSelectsPath: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal and click Browse - await waitFor(() => { - expect(canvas.getByRole("dialog")).toBeInTheDocument(); - }); - - await userEvent.click(canvas.getByText("Browse…")); - - // Wait for DirectoryPickerModal - await waitFor(() => { - expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); - }); - - // Wait for directory listing to load - await waitFor( - () => { - expect(canvas.getByText("projects")).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - - // Navigate into projects - await userEvent.click(canvas.getByText("projects")); - - // Wait for subdirectories - await waitFor( - () => { - expect(canvas.getByText("my-app")).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - - // Navigate into my-app - await userEvent.click(canvas.getByText("my-app")); - - // Wait for path update in subtitle - await waitFor( - () => { - expect(canvas.getByText("/home/user/projects/my-app")).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - - // Click Select button - await userEvent.click(canvas.getByText("Select")); - - // Directory picker should close and path should be in input - await waitFor(() => { - // DirectoryPickerModal should be closed - expect(canvas.queryByText("Select Project Directory")).not.toBeInTheDocument(); - }); - - // Check that the path was populated in the input - const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - expect(input).toHaveValue("/home/user/projects/my-app"); - }, -}; - -export const FullFlowWithDirectoryPicker: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - render: () => { - let createdPath = ""; - setupMockAPI({ - onProjectCreate: (path) => { - createdPath = path; - }, - }); - return action("created")(createdPath)} />; - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal - await waitFor(() => { - expect(canvas.getByRole("dialog")).toBeInTheDocument(); - }); - - // Click Browse - await userEvent.click(canvas.getByText("Browse…")); - - // Navigate to project directory - await waitFor(() => { - expect(canvas.getByText("projects")).toBeInTheDocument(); - }); - await userEvent.click(canvas.getByText("projects")); - - await waitFor(() => { - expect(canvas.getByText("api-server")).toBeInTheDocument(); - }); - await userEvent.click(canvas.getByText("api-server")); - - // Wait for path update - await waitFor(() => { - expect(canvas.getByText("/home/user/projects/api-server")).toBeInTheDocument(); - }); - - // Select the directory - await userEvent.click(canvas.getByText("Select")); - - // Verify path is in input - await waitFor(() => { - const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - expect(input).toHaveValue("/home/user/projects/api-server"); - }); - - // Click Add Project to complete the flow - await userEvent.click(canvas.getByRole("button", { name: "Add Project" })); - - // Modal should close after successful creation - await waitFor(() => { - expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }, -}; - -export const ValidationError: Story = { - args: { - isOpen: true, - onClose: action("close"), - onSuccess: action("success"), - }, - decorators: [ - (Story) => { - // Setup mock with validation error - const mockApi: Partial = { - fs: { - listDirectory: () => Promise.resolve(mockFileTree), - }, - projects: { - list: () => Promise.resolve([]), - create: () => - Promise.resolve({ - success: false, - error: "Not a valid git repository", - }), - remove: () => Promise.resolve({ success: true, data: undefined }), - pickDirectory: () => Promise.resolve(null), - listBranches: () => Promise.resolve({ branches: [], recommendedTrunk: "main" }), - secrets: { - get: () => Promise.resolve([]), - update: () => Promise.resolve({ success: true, data: undefined }), - }, - }, - }; - // @ts-expect-error - Mock API - window.api = mockApi; - return ; - }, - ], - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Type a path - const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - await userEvent.type(input, "/invalid/path"); - - // Click Add Project - await userEvent.click(canvas.getByRole("button", { name: "Add Project" })); - - // Wait for error message - await waitFor(() => { - expect(canvas.getByText("Not a valid git repository")).toBeInTheDocument(); - }); - }, -}; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx deleted file mode 100644 index 67f5a9794a..0000000000 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx +++ /dev/null @@ -1,486 +0,0 @@ -import React, { useRef } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ReviewPanel } from "./ReviewPanel"; -import type { IPCApi } from "@/common/types/ipc"; -import { deleteWorkspaceStorage } from "@/common/constants/storage"; -import type { BashToolResult } from "@/common/types/tools"; -import type { Result } from "@/common/types/result"; - -type ScenarioName = "rich" | "empty" | "truncated"; - -interface ScenarioConfig { - name: ScenarioName; - workspaceId: string; - workspacePath: string; - diffByFile: Record; - numstatOutput: string; - untrackedFiles: string[]; - truncated?: { - reason: string; - totalLines: number; - }; -} - -const joinLines = (lines: string[]): string => lines.join("\n"); - -const reviewPanelDiff = joinLines([ - "diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx", - "index 4b825dc..1c002b1 100644", - "--- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx", - "+++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx", - "@@ -123,6 +123,13 @@ export const ReviewPanel = ({", - " const [selectedHunkId, setSelectedHunkId] = useState(null);", - " const [isLoadingHunks, setIsLoadingHunks] = useState(true);", - " const [isLoadingTree, setIsLoadingTree] = useState(true);", - "+ const [lastInteractionTimestamp, setLastInteractionTimestamp] = useState(() => Date.now());", - "+", - "+ useEffect(() => {", - "+ setLastInteractionTimestamp(Date.now());", - "+ }, [selectedFilePath, debouncedSearchTerm]);", - "+", - "+ const idleForMs = Date.now() - lastInteractionTimestamp;", - " const [error, setError] = useState(null);", - "", - "@@ -410,7 +417,17 @@ export const ReviewPanel = ({", - " const handleRefresh = () => {", - "- setRefreshTrigger((prev) => prev + 1);", - "+ setRefreshTrigger((prev) => prev + 1);", - "+ if (idleForMs > 5000) {", - '+ console.debug("ReviewPanel idle refresh", { workspaceId, idleForMs });', - "+ }", - '+ if (typeof window !== "undefined") {', - "+ window.dispatchEvent(", - '+ new CustomEvent("review-panel:refresh", { detail: { workspaceId, idleForMs } })', - "+ );", - "+ }", - " };", - "", - "@@ -642,6 +656,14 @@ export const ReviewPanel = ({", - '-
', - '+
', - '
', - '+ ', - "+ Search", - "+ ", - " ', - " ", - "
", - " )}", -]); - -const hunkViewerDiff = joinLines([ - "diff --git a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx", - "index 6c1d2e3..9f0a1b2 100644", - "--- a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx", - "+++ b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx", - "@@ -49,6 +49,7 @@ export const HunkViewer = React.memo(", - " // Track if hunk is visible in viewport for lazy syntax highlighting", - " const isVisibleRef = React.useRef(true); // Start visible to avoid flash", - " const [isVisible, setIsVisible] = React.useState(true);", - "+ const [isPinned, setIsPinned] = React.useState(false);", - "", - "@@ -150,6 +151,13 @@ export const HunkViewer = React.memo(", - " const handleToggleExpand = React.useCallback(", - " (e?: React.MouseEvent) => {", - " e?.stopPropagation();", - " const newExpandState = !isExpanded;", - " setIsExpanded(newExpandState);", - " // Persist manual expand/collapse choice", - " setExpandStateMap((prev) => ({", - " ...prev,", - " [hunkId]: newExpandState,", - " }));", - " },", - " [isExpanded, hunkId, setExpandStateMap]", - " );", - "+", - "+ const handlePinToggle = React.useCallback((e: React.MouseEvent) => {", - "+ e.stopPropagation();", - "+ setIsPinned((prev) => !prev);", - "+ }, []);", - "", - "@@ -182,7 +190,8 @@ export const HunkViewer = React.memo(", - " className={cn(", - ' "bg-dark border rounded mb-3 overflow-hidden cursor-pointer transition-all duration-200",', - ' "focus:outline-none focus-visible:outline-none",', - ' isRead ? "border-read" : "border-border-light",', - '- isSelected && "border-review-accent shadow-[0_0_0_1px_var(--color-review-accent)]"', - '+ isSelected && "border-review-accent shadow-[0_0_0_1px_var(--color-review-accent)]",', - '+ isPinned && "ring-1 ring-review-accent/70"', - " )}", - "", - "@@ -206,6 +215,18 @@ export const HunkViewer = React.memo(", - '
', - "+ ", - "+ ", - '+ {isPinned ? "Pinned" : "Pin"}', - "+ ", - '+ ', - "+ Keep this hunk expanded while scrolling", - "+ ", - "+ ", - " {!isPureRename && (", -]); - -const useReviewStateDiff = joinLines([ - "diff --git a/src/browser/hooks/useReviewState.ts b/src/browser/hooks/useReviewState.ts", - "index 1234567..89abcde 100644", - "--- a/src/browser/hooks/useReviewState.ts", - "+++ b/src/browser/hooks/useReviewState.ts", - "@@ -43,6 +43,7 @@ export interface UseReviewStateReturn {", - " /** Mark one or more hunks as read */", - " markAsRead: (hunkIds: string | string[]) => void;", - " /** Mark a hunk as unread */", - " markAsUnread: (hunkId: string) => void;", - "+ /** Mark several hunks as unread without multiple re-renders */", - "+ markManyAsUnread: (hunkIds: string[]) => void;", - " /** Toggle read state of a hunk */", - " toggleRead: (hunkId: string) => void;", - "", - "@@ -133,6 +134,33 @@ export function useReviewState(workspaceId: string): UseReviewStateReturn {", - " const markAsUnread = useCallback(", - " (hunkId: string) => {", - " setReviewState((prev) => {", - " // Early return if not currently read", - " if (!prev.readState[hunkId]) return prev;", - "", - " const { [hunkId]: _, ...rest } = prev.readState;", - " return {", - " ...prev,", - " readState: rest,", - " lastUpdated: Date.now(),", - " };", - " });", - " },", - " [setReviewState]", - " );", - "+", - "+ const markManyAsUnread = useCallback(", - "+ (hunkIds: string[]) => {", - "+ if (hunkIds.length === 0) return;", - "+ setReviewState((prev) => {", - "+ const nextState = { ...prev.readState };", - "+ let changed = false;", - "+ for (const id of hunkIds) {", - "+ if (nextState[id]) {", - "+ delete nextState[id];", - "+ changed = true;", - "+ }", - "+ }", - "+ if (!changed) return prev;", - "+ return {", - "+ ...prev,", - "+ readState: nextState,", - "+ lastUpdated: Date.now(),", - "+ };", - "+ });", - "+ },", - "+ [setReviewState]", - "+ );", - "", - "@@ -183,6 +211,7 @@ export function useReviewState(workspaceId: string): UseReviewStateReturn {", - " return {", - " isRead,", - " markAsRead,", - " markAsUnread,", - "+ markManyAsUnread,", - " toggleRead,", - " clearAll,", - " readCount,", - " };", -]); - -const codeReviewCssDiff = joinLines([ - "diff --git a/src/styles/codeReview.css b/src/styles/codeReview.css", - "index 13579bd..2468ace 100644", - "--- a/src/styles/codeReview.css", - "+++ b/src/styles/codeReview.css", - "@@ -12,6 +12,24 @@ .code-review-panel {", - " scrollbar-color: var(--color-border-light) transparent;", - " }", - "+", - "+.code-review-panel .search-header {", - "+ position: sticky;", - "+ top: 0;", - "+ z-index: 10;", - "+ padding-block: 6px;", - "+ background: linear-gradient(180deg, rgba(12, 13, 17, 0.95), rgba(12, 13, 17, 0.6));", - "+ backdrop-filter: blur(12px);", - "+}", - "+", - "+.code-review-panel .file-tree-row.is-filtered {", - "+ color: var(--color-review-accent);", - "+ font-weight: 600;", - "+}", - "+", - "+.code-review-panel .hunk-pinned {", - "+ outline: 1px solid var(--color-review-accent);", - "+ outline-offset: 2px;", - "+ background-color: rgba(77, 184, 255, 0.05);", - "+}", -]); - -const docChecklistDiff = joinLines([ - "diff --git a/docs/review-checklist.md b/docs/review-checklist.md", - "new file mode 100644", - "index 0000000..1f5e3d4", - "--- /dev/null", - "+++ b/docs/review-checklist.md", - "@@ -0,0 +1,24 @@", - "+# Code Review Checklist", - "+", - "+Use this checklist when triaging large diffs inside the mux Code Review panel:", - "+", - "+1. Confirm keyboard shortcuts render in tooltips for every actionable item.", - "+2. Verify truncation warnings show up before the hunks and provide remediation steps.", - "+3. Ensure pinned hunks retain their state after scrolling or reloading.", - "+4. Confirm file filters highlight the active directory inside the tree.", - "+5. Skim docs and tests for every file with 50+ additions.", - "+", - "+## Reviewer Notes", - "+", - "+- Capture high-level summary of risky areas.", - "+- Note any follow-up tasks for documentation or monitoring.", - "+- Cross-link related tickets or incidents.", - "+", - "+## Sign-Off", - "+", - "+- [ ] Tests added or explicitly deemed unnecessary.", - "+- [ ] Accessibility impact reviewed.", - "+- [ ] Performance implications measured for hot paths.", - "+- [ ] Rollback plan documented.", -]); - -const themeRenameDiff = joinLines([ - "diff --git a/src/styles/theme.css b/src/styles/theme.scss", - "index 5c4b3a2..5c4b3a2 100644", - "similarity index 100%", - "rename from src/styles/theme.css", - "rename to src/styles/theme.scss", - "@@ -0,0 +0,0 @@", -]); - -const packageJsonDiff = joinLines([ - "diff --git a/package.json b/package.json", - "index a1b2c3d..d4c3b2a 100644", - "--- a/package.json", - "+++ b/package.json", - "@@ -5,7 +5,8 @@", - ' "name": "mux",', - ' "scripts": {', - '- "storybook": "storybook dev -p 6006"', - '+ "storybook": "storybook dev -p 6006",', - '+ "storybook:code-review": "storybook dev -p 6006 --docs"', - " },", - ' "devDependencies": {', - "@@ -23,4 +24,5 @@", - ' "lint-staged": {},', - '+ "codeReviewPanel": "storybook"', - " }", -]); - -const richDiffByFile: Record = { - "src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx": reviewPanelDiff, - "src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx": hunkViewerDiff, - "src/browser/hooks/useReviewState.ts": useReviewStateDiff, - "src/styles/codeReview.css": codeReviewCssDiff, - "docs/review-checklist.md": docChecklistDiff, - "src/styles/theme.scss": themeRenameDiff, - "package.json": packageJsonDiff, -}; - -const richNumstat = [ - "72\t14\tsrc/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx", - "34\t6\tsrc/browser/components/RightSidebar/CodeReview/HunkViewer.tsx", - "24\t3\tsrc/browser/hooks/useReviewState.ts", - "27\t0\tsrc/styles/codeReview.css", - "48\t0\tdocs/review-checklist.md", - "0\t0\tsrc/styles/{theme.css => theme.scss}", - "4\t1\tpackage.json", -].join("\n"); - -const WORKSPACE_PATH = "/home/user/projects/mux"; - -const scenarioConfigs: Record = { - rich: { - name: "rich", - workspaceId: "storybook-review-rich", - workspacePath: WORKSPACE_PATH, - diffByFile: richDiffByFile, - numstatOutput: richNumstat, - untrackedFiles: ["notes/review-followups.md", "scripts/smoke-review.sh"], - }, - truncated: { - name: "truncated", - workspaceId: "storybook-review-truncated", - workspacePath: WORKSPACE_PATH, - diffByFile: richDiffByFile, - numstatOutput: richNumstat, - untrackedFiles: ["notes/review-followups.md"], - truncated: { - reason: "terminal buffer limit (storybook fixture)", - totalLines: 2400, - }, - }, - empty: { - name: "empty", - workspaceId: "storybook-review-empty", - workspacePath: WORKSPACE_PATH, - diffByFile: {}, - numstatOutput: "", - untrackedFiles: ["playground/spike.ts"], - }, -}; - -function createSuccessResult( - output: string, - overrides?: { truncated?: { reason: string; totalLines: number } } -): Result { - return { - success: true as const, - data: { - success: true as const, - output, - exitCode: 0, - wall_duration_ms: 5, - ...overrides, - }, - }; -} - -function setupCodeReviewMocks(config: ScenarioConfig) { - const executeBash: IPCApi["workspace"]["executeBash"] = (_workspaceId, command) => { - if (command.includes("git ls-files --others --exclude-standard")) { - return Promise.resolve(createSuccessResult(config.untrackedFiles.join("\n"))); - } - - if (command.includes("--numstat")) { - return Promise.resolve(createSuccessResult(config.numstatOutput)); - } - - if (command.includes("git add --")) { - return Promise.resolve(createSuccessResult("")); - } - - if (command.startsWith("git diff") || command.includes("git diff ")) { - const pathRegex = / -- "([^"]+)"/; - const pathMatch = pathRegex.exec(command); - const pathFilter = pathMatch?.[1]; - const diffOutput = pathFilter - ? (config.diffByFile[pathFilter] ?? "") - : Object.values(config.diffByFile).filter(Boolean).join("\n\n"); - - const truncated = - !pathFilter && config.truncated ? { truncated: config.truncated } : undefined; - return Promise.resolve(createSuccessResult(diffOutput, truncated)); - } - - return Promise.resolve(createSuccessResult("")); - }; - - const mockApi = { - workspace: { - executeBash, - }, - platform: "browser", - versions: { - node: "18.18.0", - chrome: "120.0.0.0", - electron: "28.0.0", - }, - } as unknown as IPCApi; - - // @ts-expect-error - mockApi is not typed correctly - window.api = mockApi; - - deleteWorkspaceStorage(config.workspaceId); - localStorage.removeItem(`review-diff-base:${config.workspaceId}`); - localStorage.removeItem(`review-file-filter:${config.workspaceId}`); - localStorage.setItem("review-default-base", "HEAD"); - localStorage.setItem("review-include-uncommitted", "false"); - localStorage.setItem("review-show-read", "true"); -} - -const ReviewPanelStoryWrapper: React.FC<{ scenario: ScenarioName }> = ({ scenario }) => { - const initialized = useRef(false); - const config = scenarioConfigs[scenario]; - - if (!initialized.current) { - setupCodeReviewMocks(config); - initialized.current = true; - } - - return ( -
- -
- ); -}; - -const meta = { - title: "Panels/Code Review/ReviewPanel", - component: ReviewPanel, - parameters: { - layout: "fullscreen", - backgrounds: { - default: "panel", - values: [{ name: "panel", value: "#050505" }], - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const RichContent: Story = { - args: { - workspaceId: scenarioConfigs.rich.workspaceId, - workspacePath: scenarioConfigs.rich.workspacePath, - }, - render: () => , -}; - -export const TruncatedDiff: Story = { - args: { - workspaceId: scenarioConfigs.truncated.workspaceId, - workspacePath: scenarioConfigs.truncated.workspacePath, - }, - render: () => , -}; - -export const EmptyState: Story = { - args: { - workspaceId: scenarioConfigs.empty.workspaceId, - workspacePath: scenarioConfigs.empty.workspacePath, - }, - render: () => , -}; diff --git a/src/browser/components/Settings/Settings.stories.tsx b/src/browser/components/Settings/Settings.stories.tsx deleted file mode 100644 index 481582ef8e..0000000000 --- a/src/browser/components/Settings/Settings.stories.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, waitFor, within } from "storybook/test"; -import React, { useState } from "react"; -import { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext"; -import { SettingsModal } from "./SettingsModal"; -import type { IPCApi } from "@/common/types/ipc"; - -// Mock providers config for stories -const mockProvidersConfig: Record< - string, - { apiKeySet: boolean; baseUrl?: string; models?: string[] } -> = { - anthropic: { apiKeySet: true }, - openai: { apiKeySet: true, baseUrl: "https://custom.openai.com" }, - google: { apiKeySet: false }, - xai: { apiKeySet: false }, - ollama: { apiKeySet: false, models: ["llama3.2", "codestral"] }, - openrouter: { apiKeySet: true, models: ["mistral/mistral-7b"] }, -}; - -function setupMockAPI(config = mockProvidersConfig) { - const mockProviders: IPCApi["providers"] = { - setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), - setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => Promise.resolve(config), - list: () => Promise.resolve([]), - }; - - // @ts-expect-error - Assigning mock API to window for Storybook - window.api = { - providers: mockProviders, - }; -} - -// Wrapper component that auto-opens the settings modal -function SettingsStoryWrapper(props: { initialSection?: string }) { - return ( - - - - - ); -} - -function SettingsAutoOpen(props: { initialSection?: string }) { - const { open, isOpen } = useSettings(); - const [hasOpened, setHasOpened] = useState(false); - - React.useEffect(() => { - if (!hasOpened && !isOpen) { - open(props.initialSection); - setHasOpened(true); - } - }, [hasOpened, isOpen, open, props.initialSection]); - - return null; -} - -// Interactive wrapper for testing close behavior -function InteractiveSettingsWrapper(props: { initialSection?: string }) { - const [reopenCount, setReopenCount] = useState(0); - - return ( - -
- -
- Click overlay or press Escape to close -
-
- - -
- ); -} - -const meta = { - title: "Components/Settings", - component: SettingsModal, - parameters: { - layout: "fullscreen", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - setupMockAPI(); - return ; - }, - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -/** - * Default settings modal showing the General section. - * Contains theme toggle between light/dark modes. - */ -export const General: Story = { - render: () => , -}; - -/** - * Providers section showing API key configuration. - * - Green dot indicates configured providers - * - Accordion expands to show API Key and Base URL fields - * - Shows masked "••••••••" for set keys - */ -export const Providers: Story = { - render: () => , -}; - -/** - * Providers section with expanded Anthropic accordion. - */ -export const ProvidersExpanded: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal to render - await waitFor(async () => { - const modal = canvas.getByRole("dialog"); - await expect(modal).toBeInTheDocument(); - }); - - // Click Anthropic to expand - const anthropicButton = canvas.getByRole("button", { name: /Anthropic/i }); - await userEvent.click(anthropicButton); - - // Verify the accordion expanded (API Key label should be visible) - await waitFor(async () => { - const apiKeyLabel = canvas.getByText("API Key"); - await expect(apiKeyLabel).toBeVisible(); - }); - }, -}; - -/** - * Models section showing custom model management. - * - Form to add new models with provider dropdown - * - List of existing custom models with delete buttons - */ -export const Models: Story = { - render: () => , -}; - -/** - * Models section with no custom models configured. - */ -export const ModelsEmpty: Story = { - decorators: [ - (Story) => { - setupMockAPI({ - anthropic: { apiKeySet: true }, - openai: { apiKeySet: true }, - google: { apiKeySet: false }, - xai: { apiKeySet: false }, - ollama: { apiKeySet: false }, - openrouter: { apiKeySet: false }, - }); - return ; - }, - ], - render: () => , -}; - -/** - * Test that clicking overlay closes the modal. - */ -export const OverlayClickCloses: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal - await waitFor(async () => { - const modal = canvas.getByRole("dialog"); - await expect(modal).toBeInTheDocument(); - }); - - // Wait for event listeners to attach - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Click overlay - const overlay = document.querySelector('[role="presentation"]'); - await expect(overlay).toBeInTheDocument(); - await userEvent.click(overlay!); - - // Modal should close - await waitFor(async () => { - const closedModal = canvas.queryByRole("dialog"); - await expect(closedModal).not.toBeInTheDocument(); - }); - }, -}; - -/** - * Test that pressing Escape closes the modal. - */ -export const EscapeKeyCloses: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal - await waitFor(async () => { - const modal = canvas.getByRole("dialog"); - await expect(modal).toBeInTheDocument(); - }); - - // Wait for event listeners - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Press Escape - await userEvent.keyboard("{Escape}"); - - // Modal should close - await waitFor(async () => { - const closedModal = canvas.queryByRole("dialog"); - await expect(closedModal).not.toBeInTheDocument(); - }); - }, -}; - -/** - * Test sidebar navigation between sections. - */ -export const SidebarNavigation: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal - await waitFor(async () => { - const modal = canvas.getByRole("dialog"); - await expect(modal).toBeInTheDocument(); - }); - - // Should start on General - verify by checking theme toggle presence - await expect(canvas.getByText("Theme")).toBeVisible(); - - // Click Providers in sidebar - const providersNav = canvas.getByRole("button", { name: /Providers/i }); - await userEvent.click(providersNav); - - // Content should update to show Providers section text - await waitFor(async () => { - const providersText = canvas.getByText(/Configure API keys/i); - await expect(providersText).toBeVisible(); - }); - - // Click Models in sidebar - const modelsNav = canvas.getByRole("button", { name: /Models/i }); - await userEvent.click(modelsNav); - - // Content should update to show Models section text - await waitFor(async () => { - const modelsText = canvas.getByText(/Manage your models/i); - await expect(modelsText).toBeVisible(); - }); - }, -}; - -/** - * Test X button closes the modal. - */ -export const CloseButtonCloses: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for modal - await waitFor(async () => { - const modal = canvas.getByRole("dialog"); - await expect(modal).toBeInTheDocument(); - }); - - // Click close button - const closeButton = canvas.getByRole("button", { name: /Close settings/i }); - await userEvent.click(closeButton); - - // Modal should close - await waitFor(async () => { - const closedModal = canvas.queryByRole("dialog"); - await expect(closedModal).not.toBeInTheDocument(); - }); - }, -}; diff --git a/src/browser/components/ThinkingSlider.stories.tsx b/src/browser/components/ThinkingSlider.stories.tsx deleted file mode 100644 index a36bec3587..0000000000 --- a/src/browser/components/ThinkingSlider.stories.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; -import { ThinkingSliderComponent } from "./ThinkingSlider"; -import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; - -const meta = { - title: "Components/ThinkingSlider", - component: ThinkingSliderComponent, - parameters: { - layout: "centered", - backgrounds: { - default: "dark", - values: [{ name: "dark", value: "#1e1e1e" }], - }, - }, - tags: ["autodocs"], - argTypes: { - modelString: { - control: "text", - description: "Model name that determines thinking policy", - }, - }, - args: { - modelString: "anthropic:claude-sonnete-4-5", - }, - decorators: [ - (Story) => ( - - - - ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const DifferentModels: Story = { - render: () => ( -
-
-
- Claude Sonnet 4.5 (4 levels) -
- -
- -
-
Claude Opus 4.5 (4 levels)
- -
- -
-
Claude Opus 4.1 (4 levels)
- -
- -
-
- Gemini 3 (2 levels: low/high) -
- -
- -
-
GPT-5 Codex (4 levels)
- -
-
- ), -}; - -export const InteractiveDemo: Story = { - // Use unique workspaceId to isolate state from other stories - decorators: [ - (Story) => ( - - - - ), - ], - render: () => ( -
-
- Try moving the slider to see the purple glow effect intensify: -
- -
- • Off: No thinking (gray) -
Low: Minimal thinking (light purple) -
Medium: Moderate thinking (purple) -
High: Maximum thinking (bright purple) -
-
- ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find the slider - const slider = canvas.getByRole("slider"); - - // Verify slider is present and accessible - await expect(slider).toBeInTheDocument(); - await expect(slider).toHaveAttribute("type", "range"); - - // Initial state should be "off" (value 0) - await expect(slider).toHaveAttribute("aria-valuenow", "0"); - await expect(slider).toHaveAttribute("aria-valuetext", "off"); - - // Note: Testing actual slider interaction via keyboard/mouse is complex - // The important part is that the slider is accessible and has correct initial state - }, -}; - -export const Opus45AllLevels: Story = { - args: { modelString: "anthropic:claude-opus-4-5" }, - render: (args) => ( -
-
- Claude Opus 4.5 uses the effort parameter with optional extended thinking: -
- -
- • Off: effort=“low”, no visible reasoning -
Low: effort=“low”, visible reasoning -
Medium: effort=“medium”, visible reasoning -
High: effort=“high”, visible reasoning -
-
- ), -}; - -export const LockedThinking: Story = { - args: { modelString: "openai:gpt-5-pro" }, - render: (args) => ( -
-
- Some models have locked thinking levels based on their capabilities: -
-
-
- GPT-5-Pro (locked to “high”) -
- -
-
- Hover over the locked indicator to see why it's fixed. -
-
- ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find the level text using aria-label (should be "high" and fixed) - const levelDisplay = canvasElement.querySelector('[aria-label*="Thinking level fixed"]'); - await expect(levelDisplay).toBeInTheDocument(); - await expect(levelDisplay).toHaveTextContent("high"); - - // Verify it's a fixed level (no slider present) - const slider = canvas.queryByRole("slider"); - await expect(slider).not.toBeInTheDocument(); - - // Test passes if we verified the fixed level and no slider - // Tooltip test is skipped as it's complex with nested structure - }, -}; diff --git a/src/browser/components/TipsCarousel.stories.tsx b/src/browser/components/TipsCarousel.stories.tsx deleted file mode 100644 index 1334d84eb9..0000000000 --- a/src/browser/components/TipsCarousel.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { TipsCarousel } from "./TipsCarousel"; - -const meta = { - title: "Components/TipsCarousel", - component: TipsCarousel, - parameters: { - layout: "centered", - backgrounds: { - default: "dark", - values: [{ name: "dark", value: "#1e1e1e" }], - }, - }, - tags: ["autodocs"], - argTypes: {}, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , -}; - -export const WithExplanation: Story = { - render: () => ( -
-
- Tips rotate automatically based on time. Hover to see the gradient effect: -
- -
- Tips change every hour to provide variety and convey UX information. -
-
- ), -}; - -export const DebugControls: Story = { - render: () => ( -
-
For debugging, you can use:
- -
-
window.setTip(0) // Show first tip
-
window.setTip(1) // Show second tip
-
window.clearTip() // Return to auto-rotation
-
-
- ), -}; - -export const InContext: Story = { - render: () => { - return ( -
-
- Workspace: - main -
-
- -
-
- Mode: Plan -
-
- ); - }, -}; diff --git a/src/browser/components/ToggleGroup.stories.tsx b/src/browser/components/ToggleGroup.stories.tsx deleted file mode 100644 index aaccb16a92..0000000000 --- a/src/browser/components/ToggleGroup.stories.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import { expect, userEvent, within, waitFor } from "storybook/test"; -import { useArgs } from "storybook/preview-api"; -import { ToggleGroup, type ToggleOption } from "./ToggleGroup"; -import { useState } from "react"; -import { cn } from "@/common/lib/utils"; - -const meta = { - title: "Components/ToggleGroup", - component: ToggleGroup, - parameters: { - layout: "centered", - controls: { - exclude: ["onChange"], - }, - }, - argTypes: { - options: { - control: "object", - description: "Array of options", - }, - value: { - control: "text", - description: "Currently selected value", - }, - }, - args: { - onChange: action("value-changed"), - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const TwoOptions: Story = { - args: { - options: [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, - ], - value: "dark", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find all buttons - const lightButton = canvas.getByRole("button", { name: /light/i }); - const darkButton = canvas.getByRole("button", { name: /dark/i }); - - // Initial state - dark should be active - await expect(darkButton).toHaveAttribute("aria-pressed", "true"); - await expect(lightButton).toHaveAttribute("aria-pressed", "false"); - - // Click light button - await userEvent.click(lightButton); - - // Verify state changed using waitFor - await waitFor(() => { - void expect(lightButton).toHaveAttribute("aria-pressed", "true"); - void expect(darkButton).toHaveAttribute("aria-pressed", "false"); - }); - - // Click dark button to toggle back - await userEvent.click(darkButton); - - // Verify state changed back using waitFor - await waitFor(() => { - void expect(darkButton).toHaveAttribute("aria-pressed", "true"); - void expect(lightButton).toHaveAttribute("aria-pressed", "false"); - }); - }, -}; - -export const ThreeOptions: Story = { - args: { - options: [ - { value: "small", label: "Small" }, - { value: "medium", label: "Medium" }, - { value: "large", label: "Large" }, - ], - value: "medium", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find all buttons - const smallButton = canvas.getByRole("button", { name: /small/i }); - const mediumButton = canvas.getByRole("button", { name: /medium/i }); - const largeButton = canvas.getByRole("button", { name: /large/i }); - - // Initial state - medium should be active, others inactive - await expect(mediumButton).toHaveAttribute("aria-pressed", "true"); - await expect(smallButton).toHaveAttribute("aria-pressed", "false"); - await expect(largeButton).toHaveAttribute("aria-pressed", "false"); - - // Click small button - await userEvent.click(smallButton); - - // Verify only small is active using waitFor - await waitFor(() => { - void expect(smallButton).toHaveAttribute("aria-pressed", "true"); - void expect(mediumButton).toHaveAttribute("aria-pressed", "false"); - void expect(largeButton).toHaveAttribute("aria-pressed", "false"); - }); - - // Click large button - await userEvent.click(largeButton); - - // Verify only large is active using waitFor - await waitFor(() => { - void expect(largeButton).toHaveAttribute("aria-pressed", "true"); - void expect(smallButton).toHaveAttribute("aria-pressed", "false"); - void expect(mediumButton).toHaveAttribute("aria-pressed", "false"); - }); - }, -}; - -export const ManyOptions: Story = { - args: { - options: [ - { value: "mon", label: "Mon" }, - { value: "tue", label: "Tue" }, - { value: "wed", label: "Wed" }, - { value: "thu", label: "Thu" }, - { value: "fri", label: "Fri" }, - { value: "sat", label: "Sat" }, - { value: "sun", label: "Sun" }, - ], - value: "mon", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, -}; - -const StyledModeToggle = ({ - mode, - children, -}: { - mode: "exec" | "plan"; - children: React.ReactNode; -}) => ( -
- {children} -
-); - -export const PermissionModes: Story = { - args: { - options: [ - { value: "exec", label: "Exec" }, - { value: "plan", label: "Plan" }, - ], - value: "exec", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( -
- - updateArgs({ value: newValue })} - /> - -
- Exec (purple): AI edits files and executes commands -
- Plan (blue): AI only provides plans without executing -
-
- ); - }, -}; - -export const ViewModes: Story = { - args: { - options: [ - { value: "grid", label: "Grid View" }, - { value: "list", label: "List View" }, - ], - value: "grid", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, -}; - -export const WithStateDisplay: Story = { - args: { - options: [ - { value: "enabled", label: "Enabled" }, - { value: "disabled", label: "Disabled" }, - ], - value: "enabled", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( -
- updateArgs({ value: newValue })} - /> -
- Current selection: {value} -
-
- ); - }, -}; - -export const MultipleGroups: Story = { - parameters: { - controls: { disable: true }, - }, - args: { - options: [], - value: "", - }, - render: function Render() { - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const [size, setSize] = useState<"small" | "medium" | "large">("medium"); - const [layout, setLayout] = useState<"compact" | "comfortable" | "spacious">("comfortable"); - - const themeOptions: Array> = [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, - ]; - - const sizeOptions: Array> = [ - { value: "small", label: "S" }, - { value: "medium", label: "M" }, - { value: "large", label: "L" }, - ]; - - const layoutOptions: Array> = [ - { value: "compact", label: "Compact" }, - { value: "comfortable", label: "Comfortable" }, - { value: "spacious", label: "Spacious" }, - ]; - - const handleThemeChange = (newValue: "light" | "dark") => { - action("theme-changed")(newValue); - setTheme(newValue); - }; - - const handleSizeChange = (newValue: "small" | "medium" | "large") => { - action("size-changed")(newValue); - setSize(newValue); - }; - - const handleLayoutChange = (newValue: "compact" | "comfortable" | "spacious") => { - action("layout-changed")(newValue); - setLayout(newValue); - }; - - return ( -
-
-
Theme
- -
- -
-
Size
- -
- -
-
Layout
- -
-
- ); - }, -}; diff --git a/src/browser/components/Tooltip.stories.tsx b/src/browser/components/Tooltip.stories.tsx deleted file mode 100644 index b348633c4f..0000000000 --- a/src/browser/components/Tooltip.stories.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within, waitFor } from "storybook/test"; -import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; - -const meta = { - title: "Components/Tooltip", - component: Tooltip, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const BasicTooltip: Story = { - args: { children: "This is a helpful tooltip" }, - render: () => ( - - - This is a helpful tooltip - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find the button to hover - const button = canvas.getByRole("button", { name: /hover me/i }); - - // Initially tooltip should not be in the document - let tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).not.toBeInTheDocument(); - - // Hover over the button - await userEvent.hover(button); - - // Wait for tooltip to appear in document.body (portal) - await waitFor( - () => { - tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).toBeInTheDocument(); - void expect(tooltip).toHaveTextContent("This is a helpful tooltip"); - }, - { timeout: 2000 } - ); - - // Unhover to hide tooltip - await userEvent.unhover(button); - - // Wait for tooltip to disappear - await waitFor( - () => { - tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).not.toBeInTheDocument(); - }, - { timeout: 2000 } - ); - }, -}; - -export const TooltipPositions: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- - - Tooltip appears above - - - - - Tooltip appears below - -
- ), -}; - -export const TooltipAlignments: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- - - Left-aligned tooltip - - - - - Center-aligned tooltip - - - - - Right-aligned tooltip - -
- ), -}; - -export const WideTooltip: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - This is a wider tooltip that can contain more detailed information. It will wrap text - automatically and has a maximum width of 300px. - - - ), -}; - -export const WithHelpIndicator: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- Need help? - - ? - - Click here to open the help documentation. You can also press Cmd+Shift+H to quickly - access help. - - -
- ), -}; - -export const InlineTooltip: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- This is some text with an{" "} - - - inline tooltip - - Additional context appears here - {" "} - embedded in the sentence. -
- ), -}; - -export const KeyboardShortcut: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - Save File ⌘S - - - ), -}; - -export const LongContent: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - Getting Started: -
- 1. Create a new workspace -
- 2. Select your preferred model -
- 3. Start chatting with the AI -
-
- Press Cmd+K to open the command palette. -
-
- ), -}; diff --git a/src/browser/components/TutorialTooltip.stories.tsx b/src/browser/components/TutorialTooltip.stories.tsx deleted file mode 100644 index de66142e95..0000000000 --- a/src/browser/components/TutorialTooltip.stories.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { TutorialTooltip, type TutorialStep } from "./TutorialTooltip"; -import { TutorialProvider } from "@/browser/contexts/TutorialContext"; -import { TUTORIAL_STATE_KEY, type TutorialState } from "@/common/constants/storage"; - -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; - -const meta = { - title: "Components/TutorialTooltip", - component: TutorialTooltip, - parameters: { - layout: "centered", - // Enable tutorials for these stories - tutorialEnabled: true, - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - // Reset tutorial state to not-disabled for these stories - const enabledState: TutorialState = { - disabled: false, - completed: {}, - }; - localStorage.setItem(TUTORIAL_STATE_KEY, JSON.stringify(enabledState)); - - return ( - - - - ); - }, - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// Mock target element for positioning -const MockTargetWrapper: React.FC<{ - children: React.ReactNode; - tutorialTarget: string; -}> = ({ children, tutorialTarget }) => ( -
- - {children} -
-); - -const sampleStep: TutorialStep = { - target: "demo-target", - title: "Welcome to Mux", - content: "This is a tutorial tooltip that helps guide users through the application.", - position: "bottom", -}; - -export const SingleStep: Story = { - args: { - step: sampleStep, - currentStep: 1, - totalSteps: 1, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const MultiStepFirst: Story = { - args: { - step: { - target: "demo-target", - title: "Choose Your Model", - content: - "Select which AI model to use. Different models have different capabilities and costs.", - position: "bottom", - }, - currentStep: 1, - totalSteps: 4, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const MultiStepMiddle: Story = { - args: { - step: { - target: "demo-target", - title: "Exec vs Plan Mode", - content: - "Exec mode lets the AI edit files and run commands. Plan mode is read-only—great for exploring ideas safely.", - position: "top", - }, - currentStep: 2, - totalSteps: 4, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const MultiStepLast: Story = { - args: { - step: { - target: "demo-target", - title: "Runtime Environment", - content: "Run locally using git worktrees, or connect via SSH to work on a remote machine.", - position: "bottom", - }, - currentStep: 4, - totalSteps: 4, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -// Position variants -const PositionWrapper: React.FC<{ - children: React.ReactNode; - tutorialTarget: string; - position: "center" | "top" | "bottom" | "left" | "right"; -}> = ({ children, tutorialTarget, position }) => { - const positionClasses = { - center: "items-center justify-center", - top: "items-start justify-center pt-20", - bottom: "items-end justify-center pb-20", - left: "items-center justify-start pl-20", - right: "items-center justify-end pr-20", - }; - - return ( -
- - {children} -
- ); -}; - -export const PositionBottom: Story = { - args: { - step: { ...sampleStep, position: "bottom" }, - currentStep: 1, - totalSteps: 1, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const PositionTop: Story = { - args: { - step: { ...sampleStep, position: "top" }, - currentStep: 1, - totalSteps: 1, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const PositionLeft: Story = { - args: { - step: { ...sampleStep, position: "left" }, - currentStep: 1, - totalSteps: 1, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; - -export const PositionRight: Story = { - args: { - step: { ...sampleStep, position: "right" }, - currentStep: 1, - totalSteps: 1, - onNext: noop, - onDismiss: noop, - onDisableTutorial: noop, - }, - render: (args) => ( - - - - ), -}; diff --git a/src/browser/components/ui/CopyButton.stories.tsx b/src/browser/components/ui/CopyButton.stories.tsx deleted file mode 100644 index 3d1fa52e11..0000000000 --- a/src/browser/components/ui/CopyButton.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CopyButton } from "./CopyButton"; - -const meta: Meta = { - title: "UI/CopyButton", - component: CopyButton, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - text: "Hello, world! This text will be copied to your clipboard.", - }, -}; - -export const LongText: Story = { - args: { - text: `function example() { - console.log("This is a longer example"); - return "Copy this entire function"; -}`, - }, -}; - -export const CustomFeedback: Story = { - args: { - text: "Quick copy test", - feedbackDuration: 1000, - }, - parameters: { - docs: { - description: { - story: "The feedback duration can be customized (1 second in this example)", - }, - }, - }, -}; - -export const InContext: Story = { - render: () => ( -
-

- Some content that can be copied... -
- Multiple lines... -
- With a copy button! -

- -
- ), - parameters: { - docs: { - description: { - story: - "Example showing the copy button positioned in the bottom-right corner (hover to reveal)", - }, - }, - }, -}; diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx new file mode 100644 index 0000000000..9aa03e0a2e --- /dev/null +++ b/src/browser/stories/App.chat.stories.tsx @@ -0,0 +1,240 @@ +/** + * Chat messages & interactions stories + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { + STABLE_TIMESTAMP, + createUserMessage, + createAssistantMessage, + createFileReadTool, + createFileEditTool, + createTerminalTool, + createStatusTool, +} from "./mockFactory"; +import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers"; + +export default { + ...appMeta, + title: "App/Chat", +}; + +/** Basic chat conversation with various message types */ +export const Conversation: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + messages: [ + createUserMessage("msg-1", "Add authentication to the user API endpoint", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 300000, + }), + createAssistantMessage( + "msg-2", + "I'll help you add authentication. Let me check the current implementation.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 290000, + toolCalls: [ + createFileReadTool( + "call-1", + "src/api/users.ts", + "export function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}" + ), + ], + } + ), + createUserMessage("msg-3", "Yes, add JWT token validation", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 280000, + }), + createAssistantMessage("msg-4", "I'll add JWT validation. Here's the update:", { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 270000, + toolCalls: [ + createFileEditTool( + "call-2", + "src/api/users.ts", + [ + "--- src/api/users.ts", + "+++ src/api/users.ts", + "@@ -1,5 +1,15 @@", + "+import { verifyToken } from '../auth/jwt';", + " export function getUser(req, res) {", + "+ const token = req.headers.authorization?.split(' ')[1];", + "+ if (!token || !verifyToken(token)) {", + "+ return res.status(401).json({ error: 'Unauthorized' });", + "+ }", + " const user = db.users.find(req.params.id);", + " res.json(user);", + " }", + ].join("\n") + ), + ], + }), + ], + }); + }} + /> + ), +}; + +/** Chat with reasoning/thinking blocks */ +export const WithReasoning: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-reasoning", + messages: [ + createUserMessage("msg-1", "What about error handling if the JWT library throws?", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage( + "msg-2", + "Good catch! We should add try-catch error handling around the JWT verification.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + reasoning: + "The user is asking about error handling for JWT verification. The verifyToken function could throw if the token is malformed or if there's an issue with the secret. I should wrap it in a try-catch block and return a proper error response.", + } + ), + createAssistantMessage( + "msg-3", + "Cache is warm, shifting focus to documentation next.", + { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 80000, + reasoning: "Cache is warm already; rerunning would be redundant.", + } + ), + ], + }); + }} + /> + ), +}; + +/** Chat with terminal output showing test results */ +export const WithTerminal: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-terminal", + messages: [ + createUserMessage("msg-1", "Can you run the tests?", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage("msg-2", "Running the test suite now:", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + toolCalls: [ + createTerminalTool( + "call-1", + "npm test", + [ + "PASS src/api/users.test.ts", + " ✓ should return user when authenticated (24ms)", + " ✓ should return 401 when no token (18ms)", + " ✓ should return 401 when invalid token (15ms)", + "", + "Test Suites: 1 passed, 1 total", + "Tests: 3 passed, 3 total", + ].join("\n") + ), + ], + }), + createAssistantMessage("msg-3", "Here's a failing test for comparison:", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 80000, + toolCalls: [ + createTerminalTool( + "call-2", + "npm test -- --testNamePattern='edge case'", + [ + "FAIL src/api/users.test.ts", + " ✕ should handle edge case (45ms)", + "", + "Error: Expected 200 but got 500", + " at Object. (src/api/users.test.ts:42:5)", + "", + "Test Suites: 1 failed, 1 total", + "Tests: 1 failed, 1 total", + ].join("\n"), + 1 + ), + ], + }), + ], + }); + }} + /> + ), +}; + +/** Chat with agent status indicator */ +export const WithAgentStatus: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-status", + messages: [ + createUserMessage("msg-1", "Create a PR for the auth changes", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage( + "msg-2", + "I've created PR #1234 with the authentication changes.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + toolCalls: [ + createStatusTool( + "call-1", + "🚀", + "PR #1234 waiting for CI", + "https://github.com/example/repo/pull/1234" + ), + ], + } + ), + ], + }); + }} + /> + ), +}; + +/** Streaming/working state with pending tool call */ +export const Streaming: AppStory = { + render: () => ( + { + setupStreamingChatStory({ + messages: [ + createUserMessage("msg-1", "Refactor the database connection to use pooling", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 3000, + }), + ], + streamingMessageId: "msg-2", + historySequence: 2, + streamText: "I'll help you refactor the database connection to use connection pooling.", + pendingTool: { + toolCallId: "call-1", + toolName: "read_file", + args: { target_file: "src/db/connection.ts" }, + }, + gitStatus: { dirty: 1 }, + }); + }} + /> + ), +}; diff --git a/src/browser/stories/App.demo.stories.tsx b/src/browser/stories/App.demo.stories.tsx new file mode 100644 index 0000000000..4edbbb4d11 --- /dev/null +++ b/src/browser/stories/App.demo.stories.tsx @@ -0,0 +1,227 @@ +/** + * Comprehensive demo story exercising all features + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { + NOW, + STABLE_TIMESTAMP, + createWorkspace, + createSSHWorkspace, + groupWorkspacesByProject, + createUserMessage, + createAssistantMessage, + createFileReadTool, + createFileEditTool, + createTerminalTool, + createStatusTool, + createMockAPI, + installMockAPI, + createStaticChatHandler, + createStreamingChatHandler, + type GitStatusFixture, +} from "./mockFactory"; +import { selectWorkspace, setWorkspaceInput, setWorkspaceModel } from "./storyHelpers"; + +export default { + ...appMeta, + title: "App/Demo", +}; + +/** + * Comprehensive story showing all sidebar indicators and chat features. + * + * This exercises: + * - Multiple workspaces with varied git status + * - SSH and local runtime badges + * - Active workspace with full chat history + * - Streaming workspace showing working state + * - All tool types: read_file, file_edit, terminal, status_set + * - Reasoning blocks + * - Agent status indicator + */ +export const Comprehensive: AppStory = { + render: () => ( + { + const activeWorkspaceId = "ws-active"; + const streamingWorkspaceId = "ws-streaming"; + + const workspaces = [ + createWorkspace({ + id: activeWorkspaceId, + name: "feature/auth", + projectName: "my-app", + createdAt: new Date(NOW - 7200000).toISOString(), + }), + createWorkspace({ + id: streamingWorkspaceId, + name: "feature/streaming", + projectName: "my-app", + createdAt: new Date(NOW - 3600000).toISOString(), + }), + createWorkspace({ + id: "ws-clean", + name: "main", + projectName: "my-app", + createdAt: new Date(NOW - 10800000).toISOString(), + }), + createWorkspace({ + id: "ws-ahead", + name: "feature/new-ui", + projectName: "my-app", + createdAt: new Date(NOW - 14400000).toISOString(), + }), + createSSHWorkspace({ + id: "ws-ssh", + name: "deploy/prod", + projectName: "my-app", + host: "prod.example.com", + createdAt: new Date(NOW - 18000000).toISOString(), + }), + // Empty project to show that state + createWorkspace({ id: "ws-other", name: "main", projectName: "another-app" }), + ]; + + // Active workspace chat with full conversation + const activeMessages = [ + createUserMessage("msg-1", "Add authentication to the user API endpoint", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 300000, + }), + createAssistantMessage( + "msg-2", + "I'll help you add authentication. Let me check the current implementation.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 290000, + toolCalls: [ + createFileReadTool( + "call-1", + "src/api/users.ts", + "export function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}" + ), + ], + } + ), + createUserMessage("msg-3", "Yes, add JWT token validation", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 280000, + }), + createAssistantMessage("msg-4", "I'll add JWT validation with proper error handling.", { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 270000, + toolCalls: [ + createFileEditTool( + "call-2", + "src/api/users.ts", + [ + "--- src/api/users.ts", + "+++ src/api/users.ts", + "@@ -1,5 +1,15 @@", + "+import { verifyToken } from '../auth/jwt';", + " export function getUser(req, res) {", + "+ try {", + "+ const token = req.headers.authorization?.split(' ')[1];", + "+ if (!token || !verifyToken(token)) {", + "+ return res.status(401).json({ error: 'Unauthorized' });", + "+ }", + " const user = db.users.find(req.params.id);", + " res.json(user);", + "+ } catch (err) {", + "+ return res.status(401).json({ error: 'Invalid token' });", + "+ }", + " }", + ].join("\n") + ), + ], + }), + createUserMessage("msg-5", "Run the tests", { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 240000, + }), + createAssistantMessage("msg-6", "Running the test suite:", { + historySequence: 6, + timestamp: STABLE_TIMESTAMP - 230000, + toolCalls: [ + createTerminalTool( + "call-3", + "npm test", + [ + "PASS src/api/users.test.ts", + " ✓ should return user when authenticated (24ms)", + " ✓ should return 401 when no token (18ms)", + "", + "Test Suites: 1 passed, 1 total", + "Tests: 2 passed, 2 total", + ].join("\n") + ), + ], + }), + createAssistantMessage("msg-7", "Tests pass! I've created a PR.", { + historySequence: 7, + timestamp: STABLE_TIMESTAMP - 200000, + reasoning: "All tests pass. Time to create a PR for review.", + toolCalls: [ + createStatusTool( + "call-4", + "🚀", + "PR #1234 waiting for CI", + "https://github.com/example/repo/pull/1234" + ), + ], + }), + ]; + + // Streaming workspace messages + const streamingMessages = [ + createUserMessage("msg-s1", "Refactor the database connection", { + historySequence: 1, + timestamp: NOW - 3000, + }), + ]; + + const chatHandlers = new Map([ + [activeWorkspaceId, createStaticChatHandler(activeMessages)], + [ + streamingWorkspaceId, + createStreamingChatHandler({ + messages: streamingMessages, + streamingMessageId: "msg-s2", + model: "anthropic:claude-sonnet-4-5", + historySequence: 2, + streamText: "I'll help you refactor the database connection to use pooling.", + pendingTool: { + toolCallId: "call-s1", + toolName: "read_file", + args: { target_file: "src/db/connection.ts" }, + }, + }), + ], + ]); + + const gitStatus = new Map([ + [activeWorkspaceId, { ahead: 3, dirty: 3, headCommit: "WIP: Add JWT auth" }], + [streamingWorkspaceId, { ahead: 2, dirty: 1, headCommit: "Refactoring db" }], + ["ws-clean", {}], + ["ws-ahead", { ahead: 2, headCommit: "New dashboard design" }], + ["ws-ssh", { ahead: 1, headCommit: "Production deploy" }], + ]); + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + chatHandlers, + gitStatus, + providersList: ["anthropic", "openai", "xai"], + }) + ); + + selectWorkspace(workspaces[0]); + setWorkspaceInput(activeWorkspaceId, "Add OAuth2 support with Google and GitHub"); + setWorkspaceModel(activeWorkspaceId, "anthropic:claude-sonnet-4-5"); + }} + /> + ), +}; diff --git a/src/browser/stories/App.errors.stories.tsx b/src/browser/stories/App.errors.stories.tsx new file mode 100644 index 0000000000..8eb79856ed --- /dev/null +++ b/src/browser/stories/App.errors.stories.tsx @@ -0,0 +1,240 @@ +/** + * Error states & edge cases stories + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; +import type { MuxMessage } from "@/common/types/message"; +import { + STABLE_TIMESTAMP, + createWorkspace, + createIncompatibleWorkspace, + groupWorkspacesByProject, + createUserMessage, + createAssistantMessage, + createFileEditTool, + createStaticChatHandler, + createMockAPI, + installMockAPI, +} from "./mockFactory"; +import { selectWorkspace, setupSimpleChatStory, setupCustomChatStory } from "./storyHelpers"; + +export default { + ...appMeta, + title: "App/Errors", +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// LARGE DIFF FIXTURE +// ═══════════════════════════════════════════════════════════════════════════════ + +const LARGE_DIFF = [ + "--- src/api/users.ts", + "+++ src/api/users.ts", + "@@ -1,50 +1,80 @@", + "-// TODO: Add authentication middleware", + "-// Current implementation is insecure and allows unauthorized access", + "-// Need to validate JWT tokens before processing requests", + "-// Also need to add rate limiting to prevent abuse", + "-// Consider adding request logging for audit trail", + "-// Add input validation for user IDs", + "-// Handle edge cases for deleted/suspended users", + "-", + "-/**", + "- * Get user by ID", + "- * @param {Object} req - Express request object", + "- * @param {Object} res - Express response object", + "- */", + "-export function getUser(req, res) {", + "- // FIXME: No authentication check", + "- // FIXME: No error handling", + "- // FIXME: Synchronous database call blocks event loop", + "- const user = db.users.find(req.params.id);", + "- res.json(user);", + "-}", + "+import { verifyToken } from '../auth/jwt';", + "+import { logger } from '../utils/logger';", + "+import { validateUserId } from '../validation';", + "+", + "+/**", + "+ * Get user by ID with proper authentication and error handling", + "+ */", + "+export async function getUser(req, res) {", + "+ try {", + "+ // Validate input", + "+ const userId = validateUserId(req.params.id);", + "+ if (!userId) {", + "+ return res.status(400).json({ error: 'Invalid user ID' });", + "+ }", + "+", + "+ // Verify authentication", + "+ const token = req.headers.authorization?.split(' ')[1];", + "+ if (!token) {", + "+ logger.warn('Missing authorization token');", + "+ return res.status(401).json({ error: 'Unauthorized' });", + "+ }", + "+", + "+ const decoded = await verifyToken(token);", + "+ logger.info('User authenticated', { userId: decoded.sub });", + "+", + "+ // Fetch user with async/await", + "+ const user = await db.users.find(userId);", + "+ if (!user) {", + "+ return res.status(404).json({ error: 'User not found' });", + "+ }", + "+", + "+ // Filter sensitive fields", + "+ const safeUser = filterSensitiveFields(user);", + "+ res.json(safeUser);", + "+ } catch (err) {", + "+ logger.error('Error in getUser:', err);", + "+ return res.status(500).json({ error: 'Internal server error' });", + "+ }", + "+}", +].join("\n"); + +// ═══════════════════════════════════════════════════════════════════════════════ +// STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Stream error message in chat */ +export const StreamError: AppStory = { + render: () => ( + { + setupCustomChatStory({ + workspaceId: "ws-error", + chatHandler: (callback: (event: WorkspaceChatMessage) => void) => { + setTimeout(() => { + callback( + createUserMessage("msg-1", "Help me refactor the database layer", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }) + ); + callback({ type: "caught-up" }); + + // Simulate a stream error + callback({ + type: "stream-error", + messageId: "error-msg", + error: "Rate limit exceeded. Please wait before making more requests.", + errorType: "rate_limit", + }); + }, 50); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + }, + }); + }} + /> + ), +}; + +/** Chat with truncated/hidden history indicator */ +export const HiddenHistory: AppStory = { + render: () => ( + { + // Hidden message type uses special "hidden" role not in MuxMessage union + // Cast is needed since this is a display-only message type + const hiddenIndicator = { + id: "hidden-1", + role: "hidden", + parts: [], + metadata: { + historySequence: 0, + hiddenCount: 42, + }, + } as unknown as MuxMessage; + + const messages: MuxMessage[] = [ + hiddenIndicator, + createUserMessage("msg-1", "Can you summarize what we discussed?", { + historySequence: 43, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage( + "msg-2", + "Based on our previous conversation, we discussed implementing authentication, adding tests, and refactoring the database layer.", + { + historySequence: 44, + timestamp: STABLE_TIMESTAMP - 90000, + } + ), + ]; + + setupCustomChatStory({ + workspaceId: "ws-history", + chatHandler: createStaticChatHandler(messages), + }); + }} + /> + ), +}; + +/** + * Incompatible workspace error view. + * + * When a user downgrades to an older version of mux that doesn't support + * a workspace's runtime configuration, the workspace shows an error message + * instead of crashing. This ensures graceful degradation. + */ +export const IncompatibleWorkspace: AppStory = { + render: () => ( + { + const workspaces = [ + createWorkspace({ id: "ws-main", name: "main", projectName: "my-app" }), + createIncompatibleWorkspace({ + id: "ws-incompatible", + name: "incompatible", + projectName: "my-app", + }), + ]; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + }) + ); + + // Select the incompatible workspace + selectWorkspace(workspaces[1]); + }} + /> + ), +}; + +/** Large file diff in chat */ +export const LargeDiff: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-diff", + messages: [ + createUserMessage( + "msg-1", + "Refactor the user API with proper auth and error handling", + { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + } + ), + createAssistantMessage( + "msg-2", + "I've refactored the user API with authentication, validation, and proper error handling:", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + toolCalls: [createFileEditTool("call-1", "src/api/users.ts", LARGE_DIFF)], + } + ), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/stories/App.markdown.stories.tsx b/src/browser/stories/App.markdown.stories.tsx new file mode 100644 index 0000000000..7cf4bc4033 --- /dev/null +++ b/src/browser/stories/App.markdown.stories.tsx @@ -0,0 +1,124 @@ +/** + * Markdown rendering stories (tables, code blocks) + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory"; +import { setupSimpleChatStory } from "./storyHelpers"; + +export default { + ...appMeta, + title: "App/Markdown", +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONTENT FIXTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +const TABLE_CONTENT = `Here are various markdown table examples: + +## Simple Table + +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Value A | Value B | Value C | +| Value D | Value E | Value F | + +## Table with Alignments + +| Left | Center | Right | +|:-----|:------:|------:| +| L | C | R | +| Text | Text | Text | + +## Code in Tables + +| Feature | Status | Notes | +|---------|--------|-------| +| \`markdown\` | ✅ Done | Full GFM | +| **Bold** | ✅ Done | Works | + +## Wide Table + +| Config Key | Default | Description | Env Var | +|------------|---------|-------------|---------| +| \`api.timeout\` | 30000 | Timeout ms | \`API_TIMEOUT\` | +| \`cache.enabled\` | true | Enable cache | \`CACHE_ENABLED\` |`; + +const CODE_CONTENT = `Here's the implementation: + +\`\`\`typescript +import { verifyToken } from '../auth/jwt'; + +export async function getUser(req: Request, res: Response) { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token || !verifyToken(token)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const user = await db.users.findById(req.params.id); + res.json(user); +} +\`\`\` + +And the test: + +\`\`\`typescript +describe('getUser', () => { + it('should return 401 without token', async () => { + const res = await request(app).get('/users/1'); + expect(res.status).toBe(401); + }); +}); +\`\`\``; + +// ═══════════════════════════════════════════════════════════════════════════════ +// STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Markdown tables in chat */ +export const Tables: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-tables", + messages: [ + createUserMessage("msg-1", "Show me some table examples", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage("msg-2", TABLE_CONTENT, { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + }), + ], + }); + }} + /> + ), +}; + +/** Code blocks with syntax highlighting */ +export const CodeBlocks: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-code", + messages: [ + createUserMessage("msg-1", "Show me the code", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + }), + createAssistantMessage("msg-2", CODE_CONTENT, { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + }), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/stories/App.media.stories.tsx b/src/browser/stories/App.media.stories.tsx new file mode 100644 index 0000000000..1ef3024af2 --- /dev/null +++ b/src/browser/stories/App.media.stories.tsx @@ -0,0 +1,44 @@ +/** + * Media content stories (images) + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory"; +import { setupSimpleChatStory } from "./storyHelpers"; + +export default { + ...appMeta, + title: "App/Media", +}; + +// Placeholder image for stable visual testing +const PLACEHOLDER_IMAGE = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='150'%3E%3Crect fill='%23374151' width='200' height='150'/%3E%3Ctext fill='%239CA3AF' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EImage%3C/text%3E%3C/svg%3E"; + +/** User message with images */ +export const MessageWithImages: AppStory = { + render: () => ( + { + setupSimpleChatStory({ + workspaceId: "ws-images", + messages: [ + createUserMessage("msg-1", "Here's the screenshot of the bug:", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 100000, + images: [PLACEHOLDER_IMAGE, PLACEHOLDER_IMAGE], + }), + createAssistantMessage( + "msg-2", + "I can see the issue. The modal is rendering behind the sidebar.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 90000, + } + ), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx new file mode 100644 index 0000000000..2f49b277a8 --- /dev/null +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -0,0 +1,177 @@ +/** + * Sidebar & project navigation stories + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { + NOW, + createWorkspace, + createSSHWorkspace, + groupWorkspacesByProject, + createMockAPI, + installMockAPI, + type GitStatusFixture, +} from "./mockFactory"; + +export default { + ...appMeta, + title: "App/Sidebar", +}; + +/** Single project with multiple workspaces including SSH */ +export const SingleProject: AppStory = { + render: () => ( + { + const workspaces = [ + createWorkspace({ id: "ws-1", name: "main", projectName: "my-app" }), + createSSHWorkspace({ + id: "ws-2", + name: "feature/auth", + projectName: "my-app", + host: "dev-server.example.com", + }), + createWorkspace({ id: "ws-3", name: "bugfix/memory-leak", projectName: "my-app" }), + ]; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + }) + ); + }} + /> + ), +}; + +/** Multiple projects showing sidebar organization */ +export const MultipleProjects: AppStory = { + render: () => ( + { + const workspaces = [ + createWorkspace({ id: "ws-1", name: "main", projectName: "frontend" }), + createWorkspace({ id: "ws-2", name: "redesign", projectName: "frontend" }), + createWorkspace({ id: "ws-3", name: "main", projectName: "backend" }), + createWorkspace({ id: "ws-4", name: "api-v2", projectName: "backend" }), + createSSHWorkspace({ + id: "ws-5", + name: "db-migration", + projectName: "backend", + host: "staging.example.com", + }), + createWorkspace({ id: "ws-6", name: "main", projectName: "mobile" }), + ]; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + }) + ); + }} + /> + ), +}; + +/** Many workspaces testing sidebar scroll behavior */ +export const ManyWorkspaces: AppStory = { + render: () => ( + { + const names = [ + "main", + "develop", + "staging", + "feature/authentication", + "feature/dashboard", + "feature/notifications", + "feature/search", + "bugfix/memory-leak", + "bugfix/login-redirect", + "refactor/components", + "experiment/new-ui", + "release/v1.2.0", + ]; + + const workspaces = names.map((name, i) => + createWorkspace({ id: `ws-${i}`, name, projectName: "big-app" }) + ); + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + }) + ); + }} + /> + ), +}; + +/** All git status indicator variations */ +export const GitStatusVariations: AppStory = { + render: () => ( + { + const workspaces = [ + createWorkspace({ + id: "ws-clean", + name: "main", + projectName: "my-app", + createdAt: new Date(NOW - 3600000).toISOString(), + }), + createWorkspace({ + id: "ws-ahead", + name: "feature/new-ui", + projectName: "my-app", + createdAt: new Date(NOW - 7200000).toISOString(), + }), + createWorkspace({ + id: "ws-behind", + name: "feature/api", + projectName: "my-app", + createdAt: new Date(NOW - 10800000).toISOString(), + }), + createWorkspace({ + id: "ws-dirty", + name: "bugfix/crash", + projectName: "my-app", + createdAt: new Date(NOW - 14400000).toISOString(), + }), + createWorkspace({ + id: "ws-diverged", + name: "refactor/db", + projectName: "my-app", + createdAt: new Date(NOW - 18000000).toISOString(), + }), + createSSHWorkspace({ + id: "ws-ssh", + name: "deploy/prod", + projectName: "my-app", + host: "prod.example.com", + createdAt: new Date(NOW - 21600000).toISOString(), + }), + ]; + + const gitStatus = new Map([ + ["ws-clean", {}], + ["ws-ahead", { ahead: 2, headCommit: "Add new dashboard" }], + ["ws-behind", { behind: 3, originCommit: "Latest API changes" }], + ["ws-dirty", { dirty: 7 }], + ["ws-diverged", { ahead: 2, behind: 1, dirty: 5 }], + ["ws-ssh", { ahead: 1 }], + ]); + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + gitStatus, + }) + ); + }} + /> + ), +}; diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx new file mode 100644 index 0000000000..b75f84ad6d --- /dev/null +++ b/src/browser/stories/App.welcome.stories.tsx @@ -0,0 +1,27 @@ +/** + * Welcome/Empty state stories + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { createMockAPI, installMockAPI } from "./mockFactory"; + +export default { + ...appMeta, + title: "App/Welcome", +}; + +/** Welcome screen shown when no projects exist */ +export const WelcomeScreen: AppStory = { + render: () => ( + { + installMockAPI( + createMockAPI({ + projects: new Map(), + workspaces: [], + }) + ); + }} + /> + ), +}; diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx new file mode 100644 index 0000000000..22996c706c --- /dev/null +++ b/src/browser/stories/meta.tsx @@ -0,0 +1,47 @@ +/** + * Shared Storybook meta configuration and wrapper components. + * + * All App stories share the same meta config and AppWithMocks wrapper + * to ensure consistent setup across all story files. + */ + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FC } from "react"; +import { useRef } from "react"; +import { AppLoader } from "../components/AppLoader"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// META CONFIG +// ═══════════════════════════════════════════════════════════════════════════════ + +export const appMeta: Meta = { + title: "App", + component: AppLoader, + parameters: { + layout: "fullscreen", + backgrounds: { + default: "dark", + values: [{ name: "dark", value: "#1e1e1e" }], + }, + }, +}; + +export type AppStory = StoryObj; + +// ═══════════════════════════════════════════════════════════════════════════════ +// STORY WRAPPER +// ═══════════════════════════════════════════════════════════════════════════════ + +interface AppWithMocksProps { + setup: () => void; +} + +/** Wrapper that runs setup once before rendering */ +export const AppWithMocks: FC = ({ setup }) => { + const initialized = useRef(false); + if (!initialized.current) { + setup(); + initialized.current = true; + } + return ; +}; diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts new file mode 100644 index 0000000000..29916ebca6 --- /dev/null +++ b/src/browser/stories/mockFactory.ts @@ -0,0 +1,559 @@ +/** + * Mock factory for full-app Storybook stories. + * + * Design philosophy: + * - All visual states should be tested in context (full app), never in isolation + * - Factory provides composable building blocks for different scenarios + * - Keep mocks minimal but sufficient to exercise all visual paths + */ + +import type { ProjectConfig } from "@/node/config"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { IPCApi, WorkspaceChatMessage } from "@/common/types/ipc"; +import type { ChatStats } from "@/common/types/chatStats"; +import type { + MuxMessage, + MuxTextPart, + MuxReasoningPart, + MuxImagePart, + MuxToolPart, +} from "@/common/types/message"; + +/** Part type for message construction */ +type MuxPart = MuxTextPart | MuxReasoningPart | MuxImagePart | MuxToolPart; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// STABLE TIMESTAMPS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Fixed timestamp for deterministic visual tests (Nov 14, 2023) */ +export const NOW = 1700000000000; +/** Timestamp for messages - 1 minute ago from NOW */ +export const STABLE_TIMESTAMP = NOW - 60000; + +// ═══════════════════════════════════════════════════════════════════════════════ +// WORKSPACE FACTORY +// ═══════════════════════════════════════════════════════════════════════════════ + +export interface WorkspaceFixture { + id: string; + name: string; + projectPath: string; + projectName: string; + runtimeConfig?: RuntimeConfig; + createdAt?: string; +} + +/** Create a workspace with sensible defaults */ +export function createWorkspace( + opts: Partial & { id: string; name: string; projectName: string } +): FrontendWorkspaceMetadata { + const projectPath = opts.projectPath ?? `/home/user/projects/${opts.projectName}`; + const safeName = opts.name.replace(/\//g, "-"); + return { + id: opts.id, + name: opts.name, + projectPath, + projectName: opts.projectName, + namedWorkspacePath: `/home/user/.mux/src/${opts.projectName}/${safeName}`, + runtimeConfig: opts.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, + createdAt: opts.createdAt, + }; +} + +/** Create SSH workspace */ +export function createSSHWorkspace( + opts: Partial & { id: string; name: string; projectName: string; host: string } +): FrontendWorkspaceMetadata { + return createWorkspace({ + ...opts, + runtimeConfig: { + type: "ssh", + host: opts.host, + srcBaseDir: "/home/user/.mux/src", + }, + }); +} + +/** Create workspace with incompatible runtime (for downgrade testing) */ +export function createIncompatibleWorkspace( + opts: Partial & { + id: string; + name: string; + projectName: string; + incompatibleReason?: string; + } +): FrontendWorkspaceMetadata { + return { + ...createWorkspace(opts), + incompatibleRuntime: + opts.incompatibleReason ?? + "This workspace was created with a newer version of mux.\nPlease upgrade mux to use this workspace.", + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PROJECT FACTORY +// ═══════════════════════════════════════════════════════════════════════════════ + +export interface ProjectFixture { + path: string; + workspaces: FrontendWorkspaceMetadata[]; +} + +/** Create project config from workspaces */ +export function createProjectConfig(workspaces: FrontendWorkspaceMetadata[]): ProjectConfig { + return { + workspaces: workspaces.map((ws) => ({ + path: ws.namedWorkspacePath, + id: ws.id, + name: ws.name, + })), + }; +} + +/** Group workspaces into projects Map */ +export function groupWorkspacesByProject( + workspaces: FrontendWorkspaceMetadata[] +): Map { + const projects = new Map(); + const byProject = new Map(); + + for (const ws of workspaces) { + const existing = byProject.get(ws.projectPath) ?? []; + existing.push(ws); + byProject.set(ws.projectPath, existing); + } + + for (const [path, wsList] of byProject) { + projects.set(path, createProjectConfig(wsList)); + } + + return projects; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MESSAGE FACTORY +// ═══════════════════════════════════════════════════════════════════════════════ + +export function createUserMessage( + id: string, + text: string, + opts: { historySequence: number; timestamp?: number; images?: string[] } +): MuxMessage { + const parts: MuxPart[] = [{ type: "text", text }]; + if (opts.images) { + for (const url of opts.images) { + parts.push({ type: "file", mediaType: "image/png", url }); + } + } + return { + id, + role: "user", + parts, + metadata: { + historySequence: opts.historySequence, + timestamp: opts.timestamp ?? STABLE_TIMESTAMP, + }, + }; +} + +export function createAssistantMessage( + id: string, + text: string, + opts: { + historySequence: number; + timestamp?: number; + model?: string; + reasoning?: string; + toolCalls?: MuxPart[]; + } +): MuxMessage { + const parts: MuxPart[] = []; + if (opts.reasoning) { + parts.push({ type: "reasoning", text: opts.reasoning }); + } + parts.push({ type: "text", text }); + if (opts.toolCalls) { + parts.push(...opts.toolCalls); + } + return { + id, + role: "assistant", + parts, + metadata: { + historySequence: opts.historySequence, + timestamp: opts.timestamp ?? STABLE_TIMESTAMP, + model: opts.model ?? "anthropic:claude-sonnet-4-5", + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + duration: 1000, + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TOOL CALL FACTORY +// ═══════════════════════════════════════════════════════════════════════════════ + +export function createFileReadTool(toolCallId: string, filePath: string, content: string): MuxPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "read_file", + state: "output-available", + input: { target_file: filePath }, + output: { success: true, content }, + }; +} + +export function createFileEditTool(toolCallId: string, filePath: string, diff: string): MuxPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "file_edit_replace_string", + state: "output-available", + input: { file_path: filePath, old_string: "...", new_string: "..." }, + output: { success: true, diff, edits_applied: 1 }, + }; +} + +export function createTerminalTool( + toolCallId: string, + command: string, + output: string, + exitCode = 0 +): MuxPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "run_terminal_cmd", + state: "output-available", + input: { command, explanation: "Running command" }, + output: { success: exitCode === 0, stdout: output, exitCode }, + }; +} + +export function createStatusTool( + toolCallId: string, + emoji: string, + message: string, + url?: string +): MuxPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "status_set", + state: "output-available", + input: { emoji, message, url }, + output: { success: true, emoji, message, url }, + }; +} + +export function createPendingTool(toolCallId: string, toolName: string, args: object): MuxPart { + // Note: "input-available" is used for in-progress tool calls that haven't completed yet + return { + type: "dynamic-tool", + toolCallId, + toolName, + state: "input-available", + input: args, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// GIT STATUS MOCKS +// ═══════════════════════════════════════════════════════════════════════════════ + +export interface GitStatusFixture { + ahead?: number; + behind?: number; + dirty?: number; + headCommit?: string; + originCommit?: string; +} + +export function createGitStatusOutput(fixture: GitStatusFixture): string { + const { ahead = 0, behind = 0, dirty = 0 } = fixture; + const headCommit = fixture.headCommit ?? "Latest commit"; + const originCommit = fixture.originCommit ?? "Latest commit"; + + const lines = ["---PRIMARY---", "main", "---SHOW_BRANCH---"]; + lines.push(`! [HEAD] ${headCommit}`); + lines.push(` ! [origin/main] ${originCommit}`); + lines.push("--"); + + // Ahead commits (local only) + for (let i = 0; i < ahead; i++) { + lines.push(`- [${randomHash()}] Local commit ${i + 1}`); + } + // Behind commits (origin only) + for (let i = 0; i < behind; i++) { + lines.push(` + [${randomHash()}] Origin commit ${i + 1}`); + } + // Synced commit + if (ahead === 0 && behind === 0) { + lines.push(`++ [${randomHash()}] ${headCommit}`); + } + + lines.push("---DIRTY---"); + lines.push(String(dirty)); + + return lines.join("\n"); +} + +function randomHash(): string { + return Math.random().toString(36).substring(2, 9); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MOCK API FACTORY +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Chat handler type for onChat callbacks */ +type ChatHandler = (callback: (event: WorkspaceChatMessage) => void) => () => void; + +export interface MockAPIOptions { + projects: Map; + workspaces: FrontendWorkspaceMetadata[]; + /** Chat handlers keyed by workspace ID */ + chatHandlers?: Map; + /** Git status keyed by workspace ID */ + gitStatus?: Map; + /** Provider config */ + providersConfig?: Record; + /** Available providers list */ + providersList?: string[]; +} + +export function createMockAPI(options: MockAPIOptions): IPCApi { + const { + projects, + workspaces, + chatHandlers = new Map(), + gitStatus = new Map(), + providersConfig = {}, + providersList = [], + } = options; + + const mockStats: ChatStats = { + consumers: [], + totalTokens: 0, + model: "mock-model", + tokenizerName: "mock-tokenizer", + usageHistory: [], + }; + + return { + tokenizer: { + countTokens: () => Promise.resolve(42), + countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 42)), + calculateStats: () => Promise.resolve(mockStats), + }, + providers: { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => Promise.resolve(providersConfig), + list: () => Promise.resolve(providersList), + }, + workspace: { + create: (projectPath: string, branchName: string) => + Promise.resolve({ + success: true, + metadata: { + id: Math.random().toString(36).substring(2, 12), + name: branchName, + projectPath, + projectName: projectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/workspace/${branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }), + list: () => Promise.resolve(workspaces), + rename: (workspaceId: string) => + Promise.resolve({ + success: true, + data: { newWorkspaceId: workspaceId }, + }), + remove: () => Promise.resolve({ success: true }), + fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + openTerminal: () => Promise.resolve(undefined), + onChat: (wsId, callback) => { + const handler = chatHandlers.get(wsId); + if (handler) { + return handler(callback); + } + // Default: send caught-up immediately + setTimeout(() => callback({ type: "caught-up" }), 50); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + }, + onMetadata: () => () => undefined, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + activity: { + list: () => Promise.resolve({}), + subscribe: () => () => undefined, + }, + executeBash: (wsId: string, command: string) => { + // Return mock git status if this looks like git status script + if (command.includes("git status") || command.includes("git show-branch")) { + const emptyStatus: GitStatusFixture = {}; + const status = gitStatus.get(wsId) ?? emptyStatus; + const output = createGitStatusOutput(status); + return Promise.resolve({ + success: true, + data: { success: true, output, exitCode: 0, wall_duration_ms: 50 }, + }); + } + return Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }); + }, + }, + projects: { + list: () => Promise.resolve(Array.from(projects.entries())), + create: () => + Promise.resolve({ + success: true, + data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" }, + }), + remove: () => Promise.resolve({ success: true, data: undefined }), + pickDirectory: () => Promise.resolve(null), + listBranches: () => + Promise.resolve({ + branches: ["main", "develop", "feature/new-feature"], + recommendedTrunk: "main", + }), + secrets: { + get: () => Promise.resolve([]), + update: () => Promise.resolve({ success: true, data: undefined }), + }, + }, + window: { + setTitle: () => Promise.resolve(undefined), + }, + terminal: { + create: () => + Promise.resolve({ + sessionId: "mock-session", + workspaceId: "mock-workspace", + cols: 80, + rows: 24, + }), + close: () => Promise.resolve(undefined), + resize: () => Promise.resolve(undefined), + sendInput: () => undefined, + onOutput: () => () => undefined, + onExit: () => () => undefined, + openWindow: () => Promise.resolve(undefined), + closeWindow: () => Promise.resolve(undefined), + }, + update: { + check: () => Promise.resolve(undefined), + download: () => Promise.resolve(undefined), + install: () => undefined, + onStatus: () => () => undefined, + }, + }; +} + +/** Install mock API on window */ +export function installMockAPI(api: IPCApi): void { + // @ts-expect-error - Assigning mock API to window for Storybook + window.api = api; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CHAT SCENARIO BUILDERS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Creates a chat handler that sends messages then caught-up */ +export function createStaticChatHandler(messages: MuxMessage[]): ChatHandler { + return (callback) => { + setTimeout(() => { + for (const msg of messages) { + callback(msg); + } + callback({ type: "caught-up" }); + }, 50); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + }; +} + +/** Creates a chat handler with streaming state */ +export function createStreamingChatHandler(opts: { + messages: MuxMessage[]; + streamingMessageId: string; + model: string; + historySequence: number; + streamText?: string; + pendingTool?: { toolCallId: string; toolName: string; args: object }; +}): ChatHandler { + return (callback) => { + setTimeout(() => { + // Send historical messages + for (const msg of opts.messages) { + callback(msg); + } + callback({ type: "caught-up" }); + + // Start streaming + callback({ + type: "stream-start", + workspaceId: "mock", + messageId: opts.streamingMessageId, + model: opts.model, + historySequence: opts.historySequence, + }); + + // Send text delta if provided + if (opts.streamText) { + callback({ + type: "stream-delta", + workspaceId: "mock", + messageId: opts.streamingMessageId, + delta: opts.streamText, + tokens: 10, + timestamp: STABLE_TIMESTAMP, + }); + } + + // Send tool call start if provided + if (opts.pendingTool) { + callback({ + type: "tool-call-start", + workspaceId: "mock", + messageId: opts.streamingMessageId, + toolCallId: opts.pendingTool.toolCallId, + toolName: opts.pendingTool.toolName, + args: opts.pendingTool.args, + tokens: 5, + timestamp: STABLE_TIMESTAMP, + }); + } + }, 50); + + // Keep streaming state alive + const intervalId = setInterval(() => { + callback({ + type: "stream-delta", + workspaceId: "mock", + messageId: opts.streamingMessageId, + delta: ".", + tokens: 0, + timestamp: NOW, + }); + }, 2000); + + return () => clearInterval(intervalId); + }; +} diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts new file mode 100644 index 0000000000..d35544ba79 --- /dev/null +++ b/src/browser/stories/storyHelpers.ts @@ -0,0 +1,189 @@ +/** + * Shared story setup helpers to reduce boilerplate. + * + * These helpers encapsulate common patterns used across multiple stories, + * making each story file more focused on the specific visual state being tested. + */ + +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { MuxMessage } from "@/common/types/message"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; +import { + createWorkspace, + createMockAPI, + installMockAPI, + groupWorkspacesByProject, + createStaticChatHandler, + createStreamingChatHandler, + type GitStatusFixture, +} from "./mockFactory"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// WORKSPACE SELECTION +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Set localStorage to select a workspace */ +export function selectWorkspace(workspace: FrontendWorkspaceMetadata): void { + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ + workspaceId: workspace.id, + projectPath: workspace.projectPath, + projectName: workspace.projectName, + namedWorkspacePath: workspace.namedWorkspacePath, + }) + ); +} + +/** Set input text for a workspace */ +export function setWorkspaceInput(workspaceId: string, text: string): void { + localStorage.setItem(`input:${workspaceId}`, text); +} + +/** Set model for a workspace */ +export function setWorkspaceModel(workspaceId: string, model: string): void { + localStorage.setItem(`model:${workspaceId}`, model); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SIMPLE CHAT STORY SETUP +// ═══════════════════════════════════════════════════════════════════════════════ + +export interface SimpleChatSetupOptions { + workspaceId?: string; + workspaceName?: string; + projectName?: string; + messages: MuxMessage[]; + gitStatus?: GitStatusFixture; +} + +/** + * Setup a simple chat story with one workspace and messages. + * Handles workspace creation, mock API, and workspace selection. + */ +export function setupSimpleChatStory(opts: SimpleChatSetupOptions): void { + const workspaceId = opts.workspaceId ?? "ws-chat"; + const workspaces = [ + createWorkspace({ + id: workspaceId, + name: opts.workspaceName ?? "feature", + projectName: opts.projectName ?? "my-app", + }), + ]; + + const chatHandlers = new Map([[workspaceId, createStaticChatHandler(opts.messages)]]); + const gitStatus = opts.gitStatus + ? new Map([[workspaceId, opts.gitStatus]]) + : undefined; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + chatHandlers, + gitStatus, + }) + ); + + selectWorkspace(workspaces[0]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// STREAMING CHAT STORY SETUP +// ═══════════════════════════════════════════════════════════════════════════════ + +export interface StreamingChatSetupOptions { + workspaceId?: string; + workspaceName?: string; + projectName?: string; + messages: MuxMessage[]; + streamingMessageId: string; + model?: string; + historySequence: number; + streamText?: string; + pendingTool?: { toolCallId: string; toolName: string; args: object }; + gitStatus?: GitStatusFixture; +} + +/** + * Setup a streaming chat story with active streaming state. + */ +export function setupStreamingChatStory(opts: StreamingChatSetupOptions): void { + const workspaceId = opts.workspaceId ?? "ws-streaming"; + const workspaces = [ + createWorkspace({ + id: workspaceId, + name: opts.workspaceName ?? "feature", + projectName: opts.projectName ?? "my-app", + }), + ]; + + const chatHandlers = new Map([ + [ + workspaceId, + createStreamingChatHandler({ + messages: opts.messages, + streamingMessageId: opts.streamingMessageId, + model: opts.model ?? "anthropic:claude-sonnet-4-5", + historySequence: opts.historySequence, + streamText: opts.streamText, + pendingTool: opts.pendingTool, + }), + ], + ]); + + const gitStatus = opts.gitStatus + ? new Map([[workspaceId, opts.gitStatus]]) + : undefined; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + chatHandlers, + gitStatus, + }) + ); + + selectWorkspace(workspaces[0]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CUSTOM CHAT HANDLER SETUP +// ═══════════════════════════════════════════════════════════════════════════════ + +type ChatHandler = (callback: (event: WorkspaceChatMessage) => void) => () => void; + +export interface CustomChatSetupOptions { + workspaceId?: string; + workspaceName?: string; + projectName?: string; + chatHandler: ChatHandler; +} + +/** + * Setup a chat story with a custom chat handler for special scenarios + * (e.g., stream errors, custom message sequences). + */ +export function setupCustomChatStory(opts: CustomChatSetupOptions): void { + const workspaceId = opts.workspaceId ?? "ws-custom"; + const workspaces = [ + createWorkspace({ + id: workspaceId, + name: opts.workspaceName ?? "feature", + projectName: opts.projectName ?? "my-app", + }), + ]; + + const chatHandlers = new Map([[workspaceId, opts.chatHandler]]); + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + chatHandlers, + }) + ); + + selectWorkspace(workspaces[0]); +} From e842a2e3b5a408d7e2b1a13179b1037126f59f8c Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 21:32:53 -0600 Subject: [PATCH 2/2] feat: add Settings page stories Add stories for all Settings modal sections: - General (theme toggle) - ProvidersEmpty (no providers configured) - ProvidersConfigured (with API keys set) - ModelsEmpty (no custom models) - ModelsConfigured (with custom models) Uses play functions to open the settings modal and navigate to sections. Added @storybook/test dependency for interaction testing utilities. --- bun.lock | 62 ++++++- package.json | 1 + src/browser/stories/App.settings.stories.tsx | 184 +++++++++++++++++++ 3 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 src/browser/stories/App.settings.stories.tsx diff --git a/bun.lock b/bun.lock index 475b0fbc0a..899b1121ae 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@coder/cmux", @@ -61,6 +60,7 @@ "@storybook/addon-docs": "^10.0.0", "@storybook/addon-links": "^10.0.0", "@storybook/react-vite": "^10.0.0", + "@storybook/test": "^8.6.14", "@storybook/test-runner": "^0.24.0", "@tailwindcss/vite": "^4.1.15", "@testing-library/react": "^16.3.0", @@ -892,12 +892,16 @@ "@storybook/icons": ["@storybook/icons@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw=="], + "@storybook/instrumenter": ["@storybook/instrumenter@8.6.14", "", { "dependencies": { "@storybook/global": "^5.0.0", "@vitest/utils": "^2.1.1" }, "peerDependencies": { "storybook": "^8.6.14" } }, "sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ=="], + "@storybook/react": ["@storybook/react@10.0.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-9e0RMlMG1QJFbga258AchHQlpD9uF+uGALi63kVILm5OApVyc9sC1FGgHtVS7DrEIdW5wVCWAFLNzgSw2YFC2w=="], "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.0.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0" } }, "sha512-A4+DCu9o1F0ONpJx5yHIZ37Q7h63zxHIhK1MfDpOLfwfrapUkc/uag3WZuhwXrQMUbgFUgNA1A+8TceU5W4czA=="], "@storybook/react-vite": ["@storybook/react-vite@10.0.0", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.0.0", "@storybook/react": "10.0.0", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-2R9RHuZsPuuNZZMyL3R+h+FJ2mhkj34zIJRgWNFx+41RujOjNUBFEAxUZ7aKcmZvWLN5SRzmAwKR3g42JNtS+A=="], + "@storybook/test": ["@storybook/test@8.6.14", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/instrumenter": "8.6.14", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", "@vitest/expect": "2.0.5", "@vitest/spy": "2.0.5" }, "peerDependencies": { "storybook": "^8.6.14" } }, "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw=="], + "@storybook/test-runner": ["@storybook/test-runner@0.24.0", "", { "dependencies": { "@babel/core": "^7.22.5", "@babel/generator": "^7.22.5", "@babel/template": "^7.22.5", "@babel/types": "^7.22.5", "@jest/types": "^30.0.1", "@swc/core": "^1.5.22", "@swc/jest": "^0.2.38", "expect-playwright": "^0.8.0", "jest": "^30.0.4", "jest-circus": "^30.0.4", "jest-environment-node": "^30.0.4", "jest-junit": "^16.0.0", "jest-process-manager": "^0.4.0", "jest-runner": "^30.0.4", "jest-serializer-html": "^7.1.0", "jest-watch-typeahead": "^3.0.1", "nyc": "^15.1.0", "playwright": "^1.14.0", "playwright-core": ">=1.2.0", "rimraf": "^3.0.2", "uuid": "^8.3.2" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^10.0.0 || ^10.0.0-0" }, "bin": { "test-storybook": "dist/test-storybook.js" } }, "sha512-kEpxTUUidqMibTKWVwUBEf1+ka/wCO6kVVwl0xi7lHoxhvjOF4PyXLt6B9G2GJ+BwKJByioRbc+ywgZJuF6Vkg=="], "@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="], @@ -988,13 +992,13 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.16", "", { "dependencies": { "@tailwindcss/node": "4.1.16", "@tailwindcss/oxide": "4.1.16", "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg=="], - "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + "@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="], - "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.5.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA=="], "@testing-library/react": ["@testing-library/react@16.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw=="], - "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@testing-library/user-event": ["@testing-library/user-event@14.5.2", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ=="], "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], @@ -1274,15 +1278,15 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + "@vitest/expect": ["@vitest/expect@2.0.5", "", { "dependencies": { "@vitest/spy": "2.0.5", "@vitest/utils": "2.0.5", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA=="], "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@2.0.5", "", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="], - "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], @@ -3130,9 +3134,9 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], - "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], @@ -3464,14 +3468,22 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@2.0.5", "", { "dependencies": { "@vitest/pretty-format": "2.0.5", "estree-walker": "^3.0.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ=="], + + "@vitest/mocker/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3766,6 +3778,14 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "storybook/@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "storybook/@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -3826,12 +3846,22 @@ "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@testing-library/dom/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@testing-library/dom/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "@testing-library/jest-dom/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@2.0.5", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ=="], + + "@vitest/expect/@vitest/utils/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "@vitest/mocker/@vitest/spy/tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -3968,6 +3998,16 @@ "spawn-wrap/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "storybook/@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "storybook/@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "storybook/@vitest/spy/tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "string-length/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "wait-port/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -4014,6 +4054,8 @@ "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "wait-port/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "wait-port/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/package.json b/package.json index b702f0b581..97fdf73eba 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@storybook/addon-docs": "^10.0.0", "@storybook/addon-links": "^10.0.0", "@storybook/react-vite": "^10.0.0", + "@storybook/test": "^8.6.14", "@storybook/test-runner": "^0.24.0", "@tailwindcss/vite": "^4.1.15", "@testing-library/react": "^16.3.0", diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx new file mode 100644 index 0000000000..678009b3b3 --- /dev/null +++ b/src/browser/stories/App.settings.stories.tsx @@ -0,0 +1,184 @@ +/** + * Settings modal stories + * + * Shows different sections and states of the Settings modal: + * - General (theme toggle) + * - Providers (API key configuration) + * - Models (custom model management) + * + * Uses play functions to open the settings modal and navigate to sections. + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { + createWorkspace, + groupWorkspacesByProject, + createMockAPI, + installMockAPI, +} from "./mockFactory"; +import { selectWorkspace } from "./storyHelpers"; +import { within, waitFor, userEvent } from "@storybook/test"; + +export default { + ...appMeta, + title: "App/Settings", +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Setup basic workspace for settings stories */ +function setupSettingsStory(options: { + providersConfig?: Record; + providersList?: string[]; +}): void { + const workspaces = [createWorkspace({ id: "ws-1", name: "main", projectName: "my-app" })]; + + installMockAPI( + createMockAPI({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + providersConfig: options.providersConfig ?? {}, + providersList: options.providersList ?? ["anthropic", "openai", "xai"], + }) + ); + + selectWorkspace(workspaces[0]); +} + +/** Open settings modal and optionally navigate to a section */ +async function openSettingsToSection(canvasElement: HTMLElement, section?: string): Promise { + const canvas = within(canvasElement); + + // Wait for app to fully load (sidebar with settings button should appear) + // Use longer timeout since app initialization can take time + const settingsButton = await canvas.findByTestId("settings-button", {}, { timeout: 10000 }); + await userEvent.click(settingsButton); + + // Wait for modal to appear + await waitFor( + () => { + const modal = canvas.getByRole("dialog"); + if (!modal) throw new Error("Settings modal not found"); + }, + { timeout: 5000 } + ); + + // Navigate to specific section if requested + // The sidebar nav has buttons with exact section names + if (section && section !== "general") { + const modal = canvas.getByRole("dialog"); + const modalCanvas = within(modal); + // Find the nav section button (exact text match) + const navButtons = await modalCanvas.findAllByRole("button"); + const sectionButton = navButtons.find( + (btn) => btn.textContent?.toLowerCase().trim() === section.toLowerCase() + ); + if (!sectionButton) throw new Error(`Section button "${section}" not found`); + await userEvent.click(sectionButton); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** General settings section with theme toggle */ +export const General: AppStory = { + render: () => ( + { + setupSettingsStory({}); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "general"); + }, +}; + +/** Providers section - no providers configured */ +export const ProvidersEmpty: AppStory = { + render: () => ( + { + setupSettingsStory({ providersConfig: {} }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "providers"); + }, +}; + +/** Providers section - some providers configured */ +export const ProvidersConfigured: AppStory = { + render: () => ( + { + setupSettingsStory({ + providersConfig: { + anthropic: { apiKeySet: true, baseUrl: "" }, + openai: { apiKeySet: true, baseUrl: "https://custom.openai.com/v1" }, + xai: { apiKeySet: false, baseUrl: "" }, + }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "providers"); + }, +}; + +/** Models section - no custom models */ +export const ModelsEmpty: AppStory = { + render: () => ( + { + setupSettingsStory({ + providersConfig: { + anthropic: { apiKeySet: true, baseUrl: "", models: [] }, + openai: { apiKeySet: true, baseUrl: "", models: [] }, + }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "models"); + }, +}; + +/** Models section - with custom models configured */ +export const ModelsConfigured: AppStory = { + render: () => ( + { + setupSettingsStory({ + providersConfig: { + anthropic: { + apiKeySet: true, + baseUrl: "", + models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"], + }, + openai: { + apiKeySet: true, + baseUrl: "", + models: ["gpt-4o", "gpt-4o-mini", "o1-preview"], + }, + xai: { + apiKeySet: false, + baseUrl: "", + models: ["grok-beta"], + }, + }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "models"); + }, +};