diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx index 5d4f8e88..399848ac 100644 --- a/apps/vscode/webview-ui/src/components/ChatArea.tsx +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -15,7 +15,7 @@ function ScrollButton() { return ( diff --git a/apps/vscode/webview-ui/src/components/ChatStatus.tsx b/apps/vscode/webview-ui/src/components/ChatStatus.tsx index bcd6e1b2..a363c1eb 100644 --- a/apps/vscode/webview-ui/src/components/ChatStatus.tsx +++ b/apps/vscode/webview-ui/src/components/ChatStatus.tsx @@ -1,38 +1,79 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { useChatStore, useSettingsStore } from "@/stores"; import { cn } from "@/lib/utils"; import { IconArrowUp, IconArrowDown, IconGauge, IconRefresh, IconBolt } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +/** + * Generation speed is one property of one stream, so it is measured once here + * rather than per component. + * + * This used to be per-hook state. `TokenInfo` and `ChatStatus` are mounted at + * the same time and both call it, so each kept its own start timestamp and + * token baseline and averaged over a different window — the header and the + * expanded panel showed different numbers for the same response. + * + * The 250ms sampler also cannot depend on the token count: tokens change on + * every streamed chunk, and re-running the effect per chunk tore down the + * interval before it could fire, pinning the readout at 0 for fast streams. + */ +const speedListeners = new Set<() => void>(); +let currentSpeed = 0; +let sampler: ReturnType | null = null; +let startedAt: number | null = null; +let startTokens = 0; +let latestTokens = 0; + +function setSpeed(next: number): void { + if (next === currentSpeed) return; + currentSpeed = next; + for (const listener of speedListeners) listener(); +} + +function beginMeasuring(tokens: number): void { + startedAt = Date.now(); + startTokens = tokens; + latestTokens = tokens; + sampler ??= setInterval(() => { + if (startedAt === null) return; + const elapsedSec = (Date.now() - startedAt) / 1000; + const generated = latestTokens - startTokens; + if (elapsedSec > 0.2 && generated >= 0) setSpeed(generated / elapsedSec); + }, 250); +} + +function stopMeasuring(): void { + startedAt = null; + if (sampler !== null) { + clearInterval(sampler); + sampler = null; + } + // A finished stream has no rate; leaving the last value up made a stale + // number look live. + setSpeed(0); +} + +function subscribeToSpeed(listener: () => void): () => void { + speedListeners.add(listener); + return () => speedListeners.delete(listener); +} + export function useTokenSpeed() { const isStreaming = useChatStore((s) => s.isStreaming); const outputTokens = useChatStore((s) => s.tokenUsage.output + s.activeTokenUsage.output); + const speed = useSyncExternalStore(subscribeToSpeed, () => currentSpeed); - const [speed, setSpeed] = useState(0); - const startTimeRef = useRef(null); - const startTokensRef = useRef(0); + latestTokens = outputTokens; useEffect(() => { - if (isStreaming) { - if (startTimeRef.current === null) { - startTimeRef.current = Date.now(); - startTokensRef.current = outputTokens; - } - - const interval = setInterval(() => { - if (!startTimeRef.current) return; - const elapsedSec = (Date.now() - startTimeRef.current) / 1000; - const tokensGenerated = outputTokens - startTokensRef.current; - if (elapsedSec > 0.2 && tokensGenerated >= 0) { - setSpeed(tokensGenerated / elapsedSec); - } - }, 250); - - return () => clearInterval(interval); + if (!isStreaming) { + stopMeasuring(); + return; } - startTimeRef.current = null; - - }, [isStreaming, outputTokens]); + // Idempotent across concurrent consumers: the first one to see the stream + // start defines the window, the rest attach to the same measurement. + if (startedAt === null) beginMeasuring(latestTokens); + }, [isStreaming]); return { speed, isStreaming }; } @@ -78,7 +119,7 @@ export function TokenInfo() { Generation Speed - + {speed > 0 ? `${speed.toFixed(1)} tok/s` : "Idle"} @@ -150,9 +191,13 @@ export function ChatStatus() { {speed > 0 && ( - - - {speed.toFixed(1)} t/s + + + {/* tabular-nums keeps the pill from resizing as the rate changes, + and nowrap stops "74.7" and "t/s" breaking onto two lines in a + narrow sidebar. */} + {speed.toFixed(1)} + t/s Live Generation Speed (tokens / second) diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx index 236d53fc..7e7c0c5a 100644 --- a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx +++ b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx @@ -33,8 +33,8 @@ export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, d disabled={disabled || mode === "always"} className={cn( "flex items-center gap-0.5 justify-center h-6 min-w-6 px-1 rounded-md transition-all", - active ? "bg-blue-500/15 text-blue-500" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", - !disabled && mode !== "always" && "cursor-pointer hover:bg-blue-500/25", + active ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", + !disabled && mode !== "always" && "cursor-pointer hover:bg-primary/25", (disabled || mode === "always") && "cursor-default", )} > diff --git a/apps/vscode/webview-ui/src/components/ui/switch.tsx b/apps/vscode/webview-ui/src/components/ui/switch.tsx index 788cb052..414da096 100644 --- a/apps/vscode/webview-ui/src/components/ui/switch.tsx +++ b/apps/vscode/webview-ui/src/components/ui/switch.tsx @@ -16,20 +16,32 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - // base - "data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50", + // base — p-[2px] gives the thumb an even optical inset on all four + // sides. The previous geometry (18.4px track, 16px thumb) left ~0.6px + // above and below but 2px at the travel end, so it read as a blob + // rather than a track. + "data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent p-[2px] focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50", - // variants - variant === "default" && "data-checked:bg-primary", + // variants — the "on" track has to be lighter than the "off" track. + // `bg-primary` is oklch(0.21) in BOTH themes, darker than the unchecked + // `--input` (oklch 0.274), so turning a switch on made it recede into + // the dark background instead of lighting up. + variant === "default" && "data-checked:bg-success", variant === "blue" && "data-checked:bg-blue-400", className, )} {...props} > + {/* Travel is the track's content box minus the thumb: 32 - 2(border) - 4(padding) + - 14(thumb) = 12px, and 24 - 2 - 4 - 10 = 8px for the small size. Larger + offsets push the thumb off its inset and leave the two ends uneven. + The checked thumb uses --success-foreground rather than white: on the dark + theme's lighter green, white sits near 2:1, under the 3:1 that WCAG 1.4.11 + asks of a control that signals state by colour. */} ); diff --git a/apps/vscode/webview-ui/src/styles/index.css b/apps/vscode/webview-ui/src/styles/index.css index a51a0512..fc1b716e 100644 --- a/apps/vscode/webview-ui/src/styles/index.css +++ b/apps/vscode/webview-ui/src/styles/index.css @@ -14,15 +14,23 @@ --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); + /* Brand periwinkle, matching the CLI (lightColors.primary). Was + oklch(0.21) — a near-black that made every accent surface read as an + unstyled dark chip. */ + --primary: #4a5bc4; --primary-foreground: oklch(1 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); + /* Periwinkle, matching the CLI palette (lightColors.primary). Plain grey at + this size read as disabled rather than secondary. */ + --muted-foreground: #4a5bc4; --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); + /* CLI lightColors.success — the "on" colour for toggles. */ + --success: #0e7a38; + --success-foreground: oklch(1 0 0); --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.552 0.016 285.938); @@ -35,15 +43,23 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.18 0.005 285.823); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); + /* Brand periwinkle, matching the CLI (darkColors.primary). The old + oklch(0.21) was DARKER than --input (oklch 0.274) and barely above the + background, so accent surfaces and "on" states disappeared in dark mode. */ + --primary: #bbc6ff; + --primary-foreground: oklch(0.141 0.005 285.823); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); + /* Periwinkle, matching the CLI palette (darkColors.primary). The previous + near-neutral grey sat too close to the background to read at 11px. */ + --muted-foreground: #bbc6ff; --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + /* CLI darkColors.success — the "on" colour for toggles. */ + --success: #4ec87e; + --success-foreground: oklch(0.141 0.005 285.823); --border: oklch(0.274 0.006 286.033); --input: oklch(0.274 0.006 286.033); --ring: oklch(0.552 0.016 285.938); @@ -92,6 +108,8 @@ body { --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring);