From 4670c71678be9239cc4e25a11a34565827281162 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 05:22:39 -0400 Subject: [PATCH 1/2] fix(vscode): correct accent tokens and share one token-speed measurement --primary was oklch(0.21) in both themes: a near-black that sits DARKER than the unchecked --input (oklch 0.274) and barely above the dark background. Every accent surface built on it read as an unstyled dark chip, and a Switch turning on visibly got darker instead of lighting up. Point --primary at the CLI periwinkle per theme, and give toggles their own --success token so the on state is unambiguous. Switch geometry: p-[2px] gives the thumb an even inset. The old 18.4px track with a 16px thumb left ~0.6px above and below but 2px at the travel end, which read as a blob rather than a track. Token speed had two defects. useTokenSpeed kept per-hook state while both TokenInfo and ChatStatus call it, so each measured from its own mount time and the header and expanded panel disagreed about the same stream; the measurement now lives in one module-level sampler that every consumer subscribes to. The sampler also depended on the token count, so it was torn down and recreated on every streamed chunk and never survived its own 250ms interval on fast streams. The readout no longer wraps '74.7' and 't/s' onto separate lines. Secondary text moves from a near-neutral grey to the CLI periwinkle, which was too close to the background to read at 11px. --- .../webview-ui/src/components/ChatArea.tsx | 2 +- .../webview-ui/src/components/ChatStatus.tsx | 99 ++++++++++++++----- .../src/components/ThinkingButton.tsx | 4 +- .../webview-ui/src/components/ui/switch.tsx | 16 ++- apps/vscode/webview-ui/src/styles/index.css | 28 +++++- 5 files changed, 109 insertions(+), 40 deletions(-) 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..206bca97 100644 --- a/apps/vscode/webview-ui/src/components/ui/switch.tsx +++ b/apps/vscode/webview-ui/src/components/ui/switch.tsx @@ -16,11 +16,17 @@ 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, @@ -29,7 +35,7 @@ function Switch({ > ); 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); From aa3b340fa13c3691f2dac03e1513a5d1adb70235 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 09:34:17 -0400 Subject: [PATCH 2/2] fix(vscode): even switch thumb travel and legible checked thumb --- apps/vscode/webview-ui/src/components/ui/switch.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/vscode/webview-ui/src/components/ui/switch.tsx b/apps/vscode/webview-ui/src/components/ui/switch.tsx index 206bca97..414da096 100644 --- a/apps/vscode/webview-ui/src/components/ui/switch.tsx +++ b/apps/vscode/webview-ui/src/components/ui/switch.tsx @@ -33,9 +33,15 @@ function Switch({ )} {...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. */} );