Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/vscode/webview-ui/src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function ScrollButton() {
return (
<button
onClick={() => scrollToBottom()}
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")}
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")}
>
<IconArrowDown className="size-4" />
</button>
Expand Down
99 changes: 72 additions & 27 deletions apps/vscode/webview-ui/src/components/ChatStatus.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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<number>(0);
const startTimeRef = useRef<number | null>(null);
const startTokensRef = useRef<number>(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 };
}
Expand Down Expand Up @@ -78,7 +119,7 @@ export function TokenInfo() {
<span className="text-muted-foreground text-[10px] flex items-center gap-1">
<IconBolt className="size-3 text-amber-400" /> Generation Speed
</span>
<span className="text-sm font-semibold font-mono text-foreground mt-0.5">
<span className="text-sm font-semibold font-mono text-foreground mt-0.5 whitespace-nowrap tabular-nums">
{speed > 0 ? `${speed.toFixed(1)} tok/s` : "Idle"}
</span>
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">
Expand Down Expand Up @@ -150,9 +191,13 @@ export function ChatStatus() {
{speed > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 text-amber-500 font-mono font-medium">
<IconBolt className="size-3 fill-amber-400/20 text-amber-400 animate-pulse" />
<span>{speed.toFixed(1)} t/s</span>
<span className="flex items-center gap-1 whitespace-nowrap text-amber-500 font-mono font-medium">
<IconBolt className="size-3 shrink-0 fill-amber-400/20 text-amber-400 animate-pulse" />
{/* 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. */}
<span className="tabular-nums">{speed.toFixed(1)}</span>
<span>t/s</span>
</span>
</TooltipTrigger>
<TooltipContent>Live Generation Speed (tokens / second)</TooltipContent>
Expand Down
4 changes: 2 additions & 2 deletions apps/vscode/webview-ui/src/components/ThinkingButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
>
Expand Down
22 changes: 17 additions & 5 deletions apps/vscode/webview-ui/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
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"
className="bg-background dark:data-unchecked:bg-foreground data-checked:bg-success-foreground 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-[12px] group-data-[size=sm]/switch:data-checked:translate-x-[8px] 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"
/>
</SwitchPrimitive.Root>
);
Expand Down
28 changes: 23 additions & 5 deletions apps/vscode/webview-ui/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading