diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index a1b5fee1da..1e9064aee2 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -11,6 +11,34 @@ import type { ChatStats } from "@/common/types/chatStats"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +/** Session usage data structure matching SessionUsageFileSchema */ +export interface MockSessionUsage { + byModel: Record< + string, + { + input: { tokens: number; cost_usd?: number }; + cached: { tokens: number; cost_usd?: number }; + cacheCreate: { tokens: number; cost_usd?: number }; + output: { tokens: number; cost_usd?: number }; + reasoning: { tokens: number; cost_usd?: number }; + model?: string; + } + >; + lastRequest?: { + model: string; + usage: { + input: { tokens: number; cost_usd?: number }; + cached: { tokens: number; cost_usd?: number }; + cacheCreate: { tokens: number; cost_usd?: number }; + output: { tokens: number; cost_usd?: number }; + reasoning: { tokens: number; cost_usd?: number }; + model?: string; + }; + timestamp: number; + }; + version: 1; +} + export interface MockORPCClientOptions { projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; @@ -40,6 +68,8 @@ export interface MockORPCClientOptions { exitCode?: number; }> >; + /** Session usage data per workspace (for Costs tab) */ + sessionUsage?: Map; } /** @@ -69,6 +99,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl providersList = [], onProjectRemove, backgroundProcesses = new Map(), + sessionUsage = new Map(), } = options; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); @@ -207,7 +238,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl terminate: async () => ({ success: true, data: undefined }), sendToBackground: async () => ({ success: true, data: undefined }), }, - getSessionUsage: async () => undefined, + getSessionUsage: async (input: { workspaceId: string }) => sessionUsage.get(input.workspaceId), }, window: { setTitle: async () => undefined, diff --git a/src/browser/stories/App.rightsidebar.stories.tsx b/src/browser/stories/App.rightsidebar.stories.tsx new file mode 100644 index 0000000000..7e3962a8d7 --- /dev/null +++ b/src/browser/stories/App.rightsidebar.stories.tsx @@ -0,0 +1,137 @@ +/** + * RightSidebar tab stories - testing dynamic tab data display + * + * Uses wide viewport (1600px) to ensure RightSidebar tabs are visible. + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { setupSimpleChatStory } from "./storyHelpers"; +import { createUserMessage, createAssistantMessage } from "./mockFactory"; +import { within, userEvent, waitFor } from "@storybook/test"; +import { RIGHT_SIDEBAR_TAB_KEY } from "@/common/constants/storage"; +import type { ComponentType } from "react"; +import type { MockSessionUsage } from "../../../.storybook/mocks/orpc"; + +export default { + ...appMeta, + title: "App/RightSidebar", + decorators: [ + (Story: ComponentType) => ( +
+ +
+ ), + ], + parameters: { + ...appMeta.parameters, + chromatic: { + modes: { + dark: { theme: "dark", viewport: 1600 }, + light: { theme: "light", viewport: 1600 }, + }, + }, + }, +}; + +/** + * Helper to create session usage data with costs + */ +function createSessionUsage(cost: number): MockSessionUsage { + const inputCost = cost * 0.6; + const outputCost = cost * 0.2; + const cachedCost = cost * 0.1; + const reasoningCost = cost * 0.1; + + return { + byModel: { + "claude-sonnet-4-20250514": { + input: { tokens: 10000, cost_usd: inputCost }, + cached: { tokens: 5000, cost_usd: cachedCost }, + cacheCreate: { tokens: 0, cost_usd: 0 }, + output: { tokens: 2000, cost_usd: outputCost }, + reasoning: { tokens: 1000, cost_usd: reasoningCost }, + model: "claude-sonnet-4-20250514", + }, + }, + version: 1, + }; +} + +/** + * Costs tab with session cost displayed in tab label ($0.56) + */ +export const CostsTab: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); + + return setupSimpleChatStory({ + workspaceId: "ws-costs", + workspaceName: "feature/api", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Help me build an API", { historySequence: 1 }), + createAssistantMessage("msg-2", "I'll help you build a REST API.", { + historySequence: 2, + }), + ], + sessionUsage: createSessionUsage(0.56), + }); + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Session usage is fetched async via WorkspaceStore; wait to avoid snapshot races. + await waitFor( + () => { + canvas.getByRole("tab", { name: /costs.*\$0\.56/i }); + }, + { timeout: 5000 } + ); + }, +}; + +/** + * Review tab selected - click switches from Costs to Review tab + */ +export const ReviewTab: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); + + return setupSimpleChatStory({ + workspaceId: "ws-review", + workspaceName: "feature/review", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Add a new component", { historySequence: 1 }), + createAssistantMessage("msg-2", "I've added the component.", { historySequence: 2 }), + ], + sessionUsage: createSessionUsage(0.42), + }); + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for session usage to land (avoid theme/mode snapshots diverging on timing). + await waitFor( + () => { + canvas.getByRole("tab", { name: /costs.*\$0\.42/i }); + }, + { timeout: 5000 } + ); + + const reviewTab = canvas.getByRole("tab", { name: /^review/i }); + await userEvent.click(reviewTab); + + await waitFor(() => { + canvas.getByRole("tab", { name: /^review/i, selected: true }); + }); + }, +}; diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index 85f55d453f..d965143f71 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -26,7 +26,7 @@ import { createGitStatusOutput, type GitStatusFixture, } from "./mockFactory"; -import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; +import { createMockORPCClient, type MockSessionUsage } from "../../../.storybook/mocks/orpc"; // ═══════════════════════════════════════════════════════════════════════════════ // WORKSPACE SELECTION @@ -155,6 +155,8 @@ export interface SimpleChatSetupOptions { gitStatus?: GitStatusFixture; providersConfig?: ProvidersConfigMap; backgroundProcesses?: BackgroundProcessFixture[]; + /** Session usage data for Costs tab */ + sessionUsage?: MockSessionUsage; } /** @@ -184,6 +186,11 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { ? new Map([[workspaceId, opts.backgroundProcesses]]) : undefined; + // Set up session usage map + const sessionUsageMap = opts.sessionUsage + ? new Map([[workspaceId, opts.sessionUsage]]) + : undefined; + // Return ORPC client return createMockORPCClient({ projects: groupWorkspacesByProject(workspaces), @@ -192,6 +199,7 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { executeBash: createGitStatusExecutor(gitStatus), providersConfig: opts.providersConfig, backgroundProcesses: bgProcesses, + sessionUsage: sessionUsageMap, }); } diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index d2f7aaa272..105b9af9fe 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1003,7 +1003,8 @@ body { /* Root container */ html, body, -#root { +#root, +#storybook-root { height: 100dvh; overflow: hidden; } @@ -1079,7 +1080,8 @@ body, /* Ensure the app uses the full viewport height accounting for browser chrome */ html, body, -#root { +#root, +#storybook-root { /* Use dvh (dynamic viewport height) on supported browsers - this accounts for mobile browser chrome and keyboard accessory bars */ min-height: 100dvh; @@ -1090,7 +1092,8 @@ body, /* Handle safe areas for notched devices and keyboard accessory bars */ @supports (padding: env(safe-area-inset-top)) { /* Apply padding to account for iOS safe areas (notch at top, home indicator at bottom) */ - #root { + #root, + #storybook-root { padding-top: env(safe-area-inset-top, 0); padding-bottom: env(safe-area-inset-bottom, 0); padding-left: env(safe-area-inset-left, 0);