Skip to content

Commit 4670c71

Browse files
committed
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.
1 parent f27649d commit 4670c71

5 files changed

Lines changed: 109 additions & 40 deletions

File tree

apps/vscode/webview-ui/src/components/ChatArea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function ScrollButton() {
1515
return (
1616
<button
1717
onClick={() => scrollToBottom()}
18-
className={cn("absolute bottom-4 right-4 p-2 rounded-full z-10", "bg-blue-400 text-white shadow-lg", "hover:bg-blue-600 transition-all")}
18+
className={cn("absolute bottom-4 right-4 p-2 rounded-full z-10", "bg-primary text-primary-foreground shadow-lg", "hover:bg-primary/85 transition-all")}
1919
>
2020
<IconArrowDown className="size-4" />
2121
</button>

apps/vscode/webview-ui/src/components/ChatStatus.tsx

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,79 @@
1-
import { useState, useEffect, useRef } from "react";
1+
import { useEffect, useSyncExternalStore } from "react";
22
import { useChatStore, useSettingsStore } from "@/stores";
33
import { cn } from "@/lib/utils";
44
import { IconArrowUp, IconArrowDown, IconGauge, IconRefresh, IconBolt } from "@tabler/icons-react";
55
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
66

7+
/**
8+
* Generation speed is one property of one stream, so it is measured once here
9+
* rather than per component.
10+
*
11+
* This used to be per-hook state. `TokenInfo` and `ChatStatus` are mounted at
12+
* the same time and both call it, so each kept its own start timestamp and
13+
* token baseline and averaged over a different window — the header and the
14+
* expanded panel showed different numbers for the same response.
15+
*
16+
* The 250ms sampler also cannot depend on the token count: tokens change on
17+
* every streamed chunk, and re-running the effect per chunk tore down the
18+
* interval before it could fire, pinning the readout at 0 for fast streams.
19+
*/
20+
const speedListeners = new Set<() => void>();
21+
let currentSpeed = 0;
22+
let sampler: ReturnType<typeof setInterval> | null = null;
23+
let startedAt: number | null = null;
24+
let startTokens = 0;
25+
let latestTokens = 0;
26+
27+
function setSpeed(next: number): void {
28+
if (next === currentSpeed) return;
29+
currentSpeed = next;
30+
for (const listener of speedListeners) listener();
31+
}
32+
33+
function beginMeasuring(tokens: number): void {
34+
startedAt = Date.now();
35+
startTokens = tokens;
36+
latestTokens = tokens;
37+
sampler ??= setInterval(() => {
38+
if (startedAt === null) return;
39+
const elapsedSec = (Date.now() - startedAt) / 1000;
40+
const generated = latestTokens - startTokens;
41+
if (elapsedSec > 0.2 && generated >= 0) setSpeed(generated / elapsedSec);
42+
}, 250);
43+
}
44+
45+
function stopMeasuring(): void {
46+
startedAt = null;
47+
if (sampler !== null) {
48+
clearInterval(sampler);
49+
sampler = null;
50+
}
51+
// A finished stream has no rate; leaving the last value up made a stale
52+
// number look live.
53+
setSpeed(0);
54+
}
55+
56+
function subscribeToSpeed(listener: () => void): () => void {
57+
speedListeners.add(listener);
58+
return () => speedListeners.delete(listener);
59+
}
60+
761
export function useTokenSpeed() {
862
const isStreaming = useChatStore((s) => s.isStreaming);
963
const outputTokens = useChatStore((s) => s.tokenUsage.output + s.activeTokenUsage.output);
64+
const speed = useSyncExternalStore(subscribeToSpeed, () => currentSpeed);
1065

11-
const [speed, setSpeed] = useState<number>(0);
12-
const startTimeRef = useRef<number | null>(null);
13-
const startTokensRef = useRef<number>(0);
66+
latestTokens = outputTokens;
1467

1568
useEffect(() => {
16-
if (isStreaming) {
17-
if (startTimeRef.current === null) {
18-
startTimeRef.current = Date.now();
19-
startTokensRef.current = outputTokens;
20-
}
21-
22-
const interval = setInterval(() => {
23-
if (!startTimeRef.current) return;
24-
const elapsedSec = (Date.now() - startTimeRef.current) / 1000;
25-
const tokensGenerated = outputTokens - startTokensRef.current;
26-
if (elapsedSec > 0.2 && tokensGenerated >= 0) {
27-
setSpeed(tokensGenerated / elapsedSec);
28-
}
29-
}, 250);
30-
31-
return () => clearInterval(interval);
69+
if (!isStreaming) {
70+
stopMeasuring();
71+
return;
3272
}
33-
startTimeRef.current = null;
34-
35-
}, [isStreaming, outputTokens]);
73+
// Idempotent across concurrent consumers: the first one to see the stream
74+
// start defines the window, the rest attach to the same measurement.
75+
if (startedAt === null) beginMeasuring(latestTokens);
76+
}, [isStreaming]);
3677

3778
return { speed, isStreaming };
3879
}
@@ -78,7 +119,7 @@ export function TokenInfo() {
78119
<span className="text-muted-foreground text-[10px] flex items-center gap-1">
79120
<IconBolt className="size-3 text-amber-400" /> Generation Speed
80121
</span>
81-
<span className="text-sm font-semibold font-mono text-foreground mt-0.5">
122+
<span className="text-sm font-semibold font-mono text-foreground mt-0.5 whitespace-nowrap tabular-nums">
82123
{speed > 0 ? `${speed.toFixed(1)} tok/s` : "Idle"}
83124
</span>
84125
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">
@@ -150,9 +191,13 @@ export function ChatStatus() {
150191
{speed > 0 && (
151192
<Tooltip>
152193
<TooltipTrigger asChild>
153-
<span className="flex items-center gap-1 text-amber-500 font-mono font-medium">
154-
<IconBolt className="size-3 fill-amber-400/20 text-amber-400 animate-pulse" />
155-
<span>{speed.toFixed(1)} t/s</span>
194+
<span className="flex items-center gap-1 whitespace-nowrap text-amber-500 font-mono font-medium">
195+
<IconBolt className="size-3 shrink-0 fill-amber-400/20 text-amber-400 animate-pulse" />
196+
{/* tabular-nums keeps the pill from resizing as the rate changes,
197+
and nowrap stops "74.7" and "t/s" breaking onto two lines in a
198+
narrow sidebar. */}
199+
<span className="tabular-nums">{speed.toFixed(1)}</span>
200+
<span>t/s</span>
156201
</span>
157202
</TooltipTrigger>
158203
<TooltipContent>Live Generation Speed (tokens / second)</TooltipContent>

apps/vscode/webview-ui/src/components/ThinkingButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, d
3333
disabled={disabled || mode === "always"}
3434
className={cn(
3535
"flex items-center gap-0.5 justify-center h-6 min-w-6 px-1 rounded-md transition-all",
36-
active ? "bg-blue-500/15 text-blue-500" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
37-
!disabled && mode !== "always" && "cursor-pointer hover:bg-blue-500/25",
36+
active ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
37+
!disabled && mode !== "always" && "cursor-pointer hover:bg-primary/25",
3838
(disabled || mode === "always") && "cursor-default",
3939
)}
4040
>

apps/vscode/webview-ui/src/components/ui/switch.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@ function Switch({
1616
data-slot="switch"
1717
data-size={size}
1818
className={cn(
19-
// base
20-
"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",
19+
// base — p-[2px] gives the thumb an even optical inset on all four
20+
// sides. The previous geometry (18.4px track, 16px thumb) left ~0.6px
21+
// above and below but 2px at the travel end, so it read as a blob
22+
// rather than a track.
23+
"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",
2124

22-
// variants
23-
variant === "default" && "data-checked:bg-primary",
25+
// variants — the "on" track has to be lighter than the "off" track.
26+
// `bg-primary` is oklch(0.21) in BOTH themes, darker than the unchecked
27+
// `--input` (oklch 0.274), so turning a switch on made it recede into
28+
// the dark background instead of lighting up.
29+
variant === "default" && "data-checked:bg-success",
2430
variant === "blue" && "data-checked:bg-blue-400",
2531

2632
className,
@@ -29,7 +35,7 @@ function Switch({
2935
>
3036
<SwitchPrimitive.Thumb
3137
data-slot="switch-thumb"
32-
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
38+
className="bg-background dark:data-unchecked:bg-foreground data-checked:bg-white rounded-full group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-2.5 group-data-[size=default]/switch:data-checked:translate-x-[14px] group-data-[size=sm]/switch:data-checked:translate-x-[10px] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
3339
/>
3440
</SwitchPrimitive.Root>
3541
);

apps/vscode/webview-ui/src/styles/index.css

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@
1414
--card-foreground: oklch(0.141 0.005 285.823);
1515
--popover: oklch(1 0 0);
1616
--popover-foreground: oklch(0.141 0.005 285.823);
17-
--primary: oklch(0.21 0.006 285.885);
17+
/* Brand periwinkle, matching the CLI (lightColors.primary). Was
18+
oklch(0.21) — a near-black that made every accent surface read as an
19+
unstyled dark chip. */
20+
--primary: #4a5bc4;
1821
--primary-foreground: oklch(1 0 0);
1922
--secondary: oklch(0.967 0.001 286.375);
2023
--secondary-foreground: oklch(0.21 0.006 285.885);
2124
--muted: oklch(0.967 0.001 286.375);
22-
--muted-foreground: oklch(0.552 0.016 285.938);
25+
/* Periwinkle, matching the CLI palette (lightColors.primary). Plain grey at
26+
this size read as disabled rather than secondary. */
27+
--muted-foreground: #4a5bc4;
2328
--accent: oklch(0.967 0.001 286.375);
2429
--accent-foreground: oklch(0.21 0.006 285.885);
2530
--destructive: oklch(0.577 0.245 27.325);
31+
/* CLI lightColors.success — the "on" colour for toggles. */
32+
--success: #0e7a38;
33+
--success-foreground: oklch(1 0 0);
2634
--border: oklch(0.92 0.004 286.32);
2735
--input: oklch(0.92 0.004 286.32);
2836
--ring: oklch(0.552 0.016 285.938);
@@ -35,15 +43,23 @@
3543
--card-foreground: oklch(0.985 0 0);
3644
--popover: oklch(0.18 0.005 285.823);
3745
--popover-foreground: oklch(0.985 0 0);
38-
--primary: oklch(0.21 0.006 285.885);
39-
--primary-foreground: oklch(0.985 0 0);
46+
/* Brand periwinkle, matching the CLI (darkColors.primary). The old
47+
oklch(0.21) was DARKER than --input (oklch 0.274) and barely above the
48+
background, so accent surfaces and "on" states disappeared in dark mode. */
49+
--primary: #bbc6ff;
50+
--primary-foreground: oklch(0.141 0.005 285.823);
4051
--secondary: oklch(0.274 0.006 286.033);
4152
--secondary-foreground: oklch(0.985 0 0);
4253
--muted: oklch(0.274 0.006 286.033);
43-
--muted-foreground: oklch(0.705 0.015 286.067);
54+
/* Periwinkle, matching the CLI palette (darkColors.primary). The previous
55+
near-neutral grey sat too close to the background to read at 11px. */
56+
--muted-foreground: #bbc6ff;
4457
--accent: oklch(0.274 0.006 286.033);
4558
--accent-foreground: oklch(0.985 0 0);
4659
--destructive: oklch(0.704 0.191 22.216);
60+
/* CLI darkColors.success — the "on" colour for toggles. */
61+
--success: #4ec87e;
62+
--success-foreground: oklch(0.141 0.005 285.823);
4763
--border: oklch(0.274 0.006 286.033);
4864
--input: oklch(0.274 0.006 286.033);
4965
--ring: oklch(0.552 0.016 285.938);
@@ -92,6 +108,8 @@ body {
92108
--color-accent: var(--accent);
93109
--color-accent-foreground: var(--accent-foreground);
94110
--color-destructive: var(--destructive);
111+
--color-success: var(--success);
112+
--color-success-foreground: var(--success-foreground);
95113
--color-border: var(--border);
96114
--color-input: var(--input);
97115
--color-ring: var(--ring);

0 commit comments

Comments
 (0)