feat: harden workflow gates and expose token usage
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user