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);