Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ProjectConfig>;
workspaces?: FrontendWorkspaceMetadata[];
Expand Down Expand Up @@ -40,6 +68,8 @@ export interface MockORPCClientOptions {
exitCode?: number;
}>
>;
/** Session usage data per workspace (for Costs tab) */
sessionUsage?: Map<string, MockSessionUsage>;
}

/**
Expand Down Expand Up @@ -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]));
Expand Down Expand Up @@ -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,
Expand Down
137 changes: 137 additions & 0 deletions src/browser/stories/App.rightsidebar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{ width: 1600, height: "100dvh" }}>
<Story />
</div>
),
],
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: () => (
<AppWithMocks
setup={() => {
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: () => (
<AppWithMocks
setup={() => {
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 });
});
},
};
10 changes: 9 additions & 1 deletion src/browser/stories/storyHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -155,6 +155,8 @@ export interface SimpleChatSetupOptions {
gitStatus?: GitStatusFixture;
providersConfig?: ProvidersConfigMap;
backgroundProcesses?: BackgroundProcessFixture[];
/** Session usage data for Costs tab */
sessionUsage?: MockSessionUsage;
}

/**
Expand Down Expand Up @@ -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),
Expand All @@ -192,6 +199,7 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient {
executeBash: createGitStatusExecutor(gitStatus),
providersConfig: opts.providersConfig,
backgroundProcesses: bgProcesses,
sessionUsage: sessionUsageMap,
});
}

Expand Down
9 changes: 6 additions & 3 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,8 @@ body {
/* Root container */
html,
body,
#root {
#root,
#storybook-root {
height: 100dvh;
overflow: hidden;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Loading