diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..448f767a0b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "studio", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "studio", "run", "dev"], + "port": 3000 + } + ] +} diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx index 863e38e045..f37428380b 100644 --- a/studio/src/app/workspace/_components/chat-input.tsx +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -20,7 +20,14 @@ import { Shield, SlidersHorizontal, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -63,6 +70,9 @@ interface ProjectItem { interface ChatInputProps { placeholder?: string; rows?: number; + /** The context-utilisation strip, rendered at the right end of the toolbar + row below the input (typography matches the pills' value text). */ + contextMeter?: ReactNode; projects?: ProjectItem[]; selectedProjectId?: string | null; onSelectProject?: (id: string | null) => void; @@ -1149,6 +1159,7 @@ function handleMenuNavKey( export function ChatInput({ placeholder: placeholderProp, + contextMeter, projects, selectedProjectId, onSelectProject, @@ -1700,6 +1711,7 @@ export function ChatInput({ )} + {contextMeter} ); diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx index 5f3a4f7637..4b379e245a 100644 --- a/studio/src/app/workspace/chat/_components/chat-view.tsx +++ b/studio/src/app/workspace/chat/_components/chat-view.tsx @@ -1268,17 +1268,7 @@ export function ChatView({ )} -
- {/* B1.1: effective model + approximate context utilisation, - visible only when the window is known and tokens counted. */} - {contextInfo && contextInfo.contextWindow > 0 && ( - - )} +
onSteerQueued?.(id)} @@ -1318,6 +1308,18 @@ export function ChatView({ onPreviewAttachment={handlePreviewFile} focusKey={session.id} mobileDocked + // B1.1: effective model + approximate context utilisation, + // leftmost in the toolbar, visible only when the window is + // known and tokens counted. + contextMeter={ + contextInfo && contextInfo.contextWindow > 0 ? ( + + ) : undefined + } modelLockedLabel={ live ? session.model || "Auto-routed" : undefined } diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx index 968f178fe7..ce3e10e38d 100644 --- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx +++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx @@ -325,7 +325,7 @@ function DraftView({
-
+
{error &&

{error}

} { expect(contextUtilisation(-5, 100, 1_000)).toBeCloseTo(0.1); }); }); + +/** + * The rendered strip lives at the right end of the composer toolbar: a short + * bar plus a plain "N% used" (the tooltip owns the approximation caveat and + * explains what a context window is), text matching the toolbar pills' value + * typography (text-sm, muted — the "On" in "Memory On"), no model name. + */ +describe("ContextMeter render", () => { + const props = { + contextWindow: 400_000, + inputTokens: 80_000, + outputTokens: 8_000, + }; + + it("shows the plain percentage as used, no approximate tag", () => { + const { container } = render(createElement(ContextMeter, props)); + expect(container.textContent).toContain("22% used"); + expect(container.textContent).not.toContain("~"); + expect(container.textContent).not.toContain("of context"); + expect(container.textContent).not.toContain("approximate"); + }); + + it("docks right with a short fixed bar and no model name", () => { + const { container } = render(createElement(ContextMeter, props)); + const root = container.firstElementChild; + expect(root?.className).toContain("ml-auto"); + expect(root?.innerHTML).toContain("w-10"); + expect(container.textContent?.trim()).toBe("22% used"); + }); + + it("uses the toolbar pills' value typography", () => { + const { container } = render(createElement(ContextMeter, props)); + const root = container.firstElementChild; + expect(root?.className).toContain("text-sm"); + expect(root?.className).toContain("text-muted-foreground"); + }); + + it("stays quiet until this visit has counted tokens", () => { + const { container } = render( + createElement(ContextMeter, { + ...props, + inputTokens: 0, + outputTokens: 0, + }), + ); + expect(container.firstElementChild).toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/context-meter.tsx b/studio/src/app/workspace/chat/_components/context-meter.tsx index 850dec4111..71bc23a0d5 100644 --- a/studio/src/app/workspace/chat/_components/context-meter.tsx +++ b/studio/src/app/workspace/chat/_components/context-meter.tsx @@ -1,15 +1,21 @@ "use client"; /** - * The slim context strip near the composer (B1.1): the session's effective - * model and an APPROXIMATE context-utilisation meter — cumulative input+output + * The context strip at the right end of the composer toolbar (B1.1): a short + * bar plus an APPROXIMATE context-utilisation figure — cumulative input+output * tokens this visit counted (summed from the runs' terminal usage frames) vs - * the model's `resolved_model.context_window`. Deliberately labeled - * approximate: the daemon's true compaction trigger also counts the system - * prompt and tool schemas, which the client never sees, and a page reload - * loses the visit's running total. + * the model's `resolved_model.context_window`. The reading is approximate — + * the daemon's true compaction trigger also counts the system prompt and tool + * schemas, which the client never sees, and a page reload loses the visit's + * running total — which the tooltip explains. Typography matches the toolbar + * pills' value text (the "On" in "Memory On"). */ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; /** @@ -28,13 +34,10 @@ export function contextUtilisation( } export function ContextMeter({ - modelLabel, contextWindow, inputTokens, outputTokens, }: { - /** The effective model (resolved_model.model_id). */ - modelLabel: string; contextWindow: number; inputTokens: number; outputTokens: number; @@ -45,26 +48,30 @@ export function ContextMeter({ if (fraction === null || inputTokens + outputTokens <= 0) return null; const percent = Math.round(fraction * 100); return ( -
- {modelLabel && {modelLabel}} -
+ + +
+
+
+ + Roughly how much of the model's working memory this chat has used. + When it fills up, older messages are summarized to make room. + +
); }