feat: harden workflow gates and expose token usage

This commit is contained in:
2026-07-21 12:14:26 +02:00
parent 8aa1676811
commit 2ff63d371f
20 changed files with 604 additions and 53 deletions
+49 -1
View File
@@ -2,8 +2,10 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { SteerInput } from "./SteerInput";
import { useSessionStore } from "../store/sessionStore";
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
beforeEach(() => {
server.use(
@@ -83,6 +85,52 @@ test("marks the composer as awaiting input only when requested", () => {
expect(input).toHaveClass("thot-awaiting-input");
});
test("the context gauge uses green, yellow, and red at the requested thresholds", () => {
const { rerender } = render(<ContextGauge pct={0.699} />);
expect(screen.getByLabelText("Context usage 70%")).toHaveAttribute("data-level", "green");
rerender(<ContextGauge pct={0.7} />);
expect(screen.getByLabelText("Context usage 70%")).toHaveAttribute("data-level", "yellow");
rerender(<ContextGauge pct={0.85} />);
expect(screen.getByLabelText("Context usage 85%")).toHaveAttribute("data-level", "yellow");
rerender(<ContextGauge pct={0.851} />);
expect(screen.getByLabelText("Context usage 85%")).toHaveAttribute("data-level", "red");
});
test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => {
server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
})),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])),
http.get("http://localhost:8787/models", () => HttpResponse.json({
models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }],
})),
);
useSessionStore.getState().resetSession();
useSessionStore.getState().applyEvent({
type: "usage",
usage: {
input: 110_348,
cacheRead: 633_344,
output: 33_652,
totalTokens: 55_935,
contextWindow: 200_000,
},
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><ComposerFooter /></QueryClientProvider>);
const counters = screen.getByText("110k/633k/34k");
const workspace = await screen.findByRole("combobox", { name: "Workspace" });
const gauge = screen.getByLabelText("Context usage 28%");
const thinking = await screen.findByRole("combobox", { name: "Thinking level" });
expect(workspace.compareDocumentPosition(counters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(thinking.compareDocumentPosition(gauge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("pulses the stop dot only while the harness is working", () => {
const { rerender } = render(<SteerInput sessionId="s1" working />);
const dot = () =>
+52 -19
View File
@@ -151,9 +151,8 @@ export function SteerInput({
/**
* Status strip beneath the composer. Left: the active workspace selector.
* Right: live model + thinking-level selectors (these replace the old Settings
* dialog and persist through PUT /settings) plus a context-usage gauge
* (placeholder until the backend reports token usage).
* Right: live context usage plus model + thinking-level selectors (these replace
* the old Settings dialog and persist through PUT /settings).
*/
export function ComposerFooter() {
const qc = useQueryClient();
@@ -161,6 +160,7 @@ export function ComposerFooter() {
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
const models = modelsData?.models ?? [];
const tokenUsage = useSessionStore((state) => state.tokenUsage);
const workspace = settings?.workspace ?? "";
const model = settings?.model ?? "";
@@ -178,20 +178,31 @@ export function ComposerFooter() {
}
const knownModel = models.some((m) => m.id === model);
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
? tokenUsage.totalTokens / tokenUsage.contextWindow
: 0;
return (
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
) : (
workspaces.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))
)}
</FooterSelect>
<div className="flex items-center gap-2">
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
) : (
workspaces.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))
)}
</FooterSelect>
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
aria-label="Input non-cached, cached, and output tokens"
>
{formatTokenUsage(tokenUsage)}
</span>
</div>
<div className="flex items-center gap-2">
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
@@ -215,7 +226,7 @@ export function ComposerFooter() {
</option>
))}
</FooterSelect>
<ContextGauge />
<ContextGauge pct={contextPct} />
</div>
</div>
);
@@ -249,14 +260,36 @@ function FooterSelect({
);
}
function formatThousands(tokens: number): string {
return `${Math.round(Math.max(tokens, 0) / 1_000)}k`;
}
export function formatTokenUsage(usage: {
input: number;
cacheRead: number;
output: number;
} | null): string {
return [usage?.input ?? 0, usage?.cacheRead ?? 0, usage?.output ?? 0]
.map(formatThousands)
.join("/");
}
/** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */
function ContextGauge({ pct = 0 }: { pct?: number }) {
export function ContextGauge({ pct = 0 }: { pct?: number }) {
const r = 6;
const circ = 2 * Math.PI * r;
const clamped = Math.min(Math.max(pct, 0), 1);
const level = clamped < 0.7 ? "green" : clamped <= 0.85 ? "yellow" : "red";
const progressClass = level === "green"
? "stroke-emerald-500"
: level === "yellow"
? "stroke-amber-400"
: "stroke-red-500";
return (
<span
title="Context usage"
aria-label="Context usage"
aria-label={`Context usage ${Math.round(clamped * 100)}%`}
data-level={level}
className="inline-flex items-center"
>
<svg width="16" height="16" viewBox="0 0 16 16" className="opacity-80">
@@ -266,11 +299,11 @@ function ContextGauge({ pct = 0 }: { pct?: number }) {
cy="8"
r={r}
fill="none"
stroke="oklch(var(--primary))"
className={progressClass}
strokeWidth="2.25"
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={circ * (1 - Math.min(Math.max(pct, 0), 1))}
strokeDashoffset={circ * (1 - clamped)}
transform="rotate(-90 8 8)"
/>
</svg>