fix: clarify Claude CLI billing and compact status copy
This commit is contained in:
@@ -40,7 +40,7 @@ export function ClaudeCodeInstallationStatus() {
|
|||||||
return () => request.current?.abort();
|
return () => request.current?.abort();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
return <div className="space-y-2" data-testid="claude-code-installation">
|
return <div className="space-y-2" data-testid="claude-code-installation">
|
||||||
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
|
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" actionVariant="outline" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
|
||||||
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
|
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
|
||||||
</ConnectionRow>
|
</ConnectionRow>
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.</p>
|
<p className="text-xs leading-relaxed text-muted-foreground">Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.</p>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { createPortal } from "react-dom";
|
|||||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||||
|
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
|
||||||
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||||
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
||||||
{children}
|
{children}
|
||||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ export function StatsBar({
|
|||||||
// otherwise "—" (never a fake $0.0000).
|
// otherwise "—" (never a fake $0.0000).
|
||||||
const pricedCount = turns.length - unpriced - subscription;
|
const pricedCount = turns.length - unpriced - subscription;
|
||||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
|
||||||
|
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -164,7 +165,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
|
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
|
||||||
<p>The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.</p>
|
<p>Window reported by Claude Code; may be lower for auto-compaction.</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||||
@@ -193,7 +194,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseHTML } from "linkedom";
|
|||||||
import { StatsBar } from "../components/stats-bar.tsx";
|
import { StatsBar } from "../components/stats-bar.tsx";
|
||||||
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
||||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||||
import type { ModelCatalogResponse } from "../src/core/index.ts";
|
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
|
||||||
|
|
||||||
const dom = parseHTML("<html><body></body></html>");
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
|
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
|
||||||
@@ -27,14 +27,17 @@ test("compact CLI context is numeric only; effective-window explanation appears
|
|||||||
throw new Error(`unexpected request ${url}`);
|
throw new Error(`unexpected request ${url}`);
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||||
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[]} contextTokens={42000} cliContextWindow={window} />);
|
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] };
|
||||||
|
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[turn]} contextTokens={42000} cliContextWindow={window} />);
|
||||||
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
|
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
|
||||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
||||||
assert(!document.body.textContent?.includes("auto-compaction"));
|
assert(!document.body.textContent?.includes("auto-compaction"));
|
||||||
|
assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI");
|
||||||
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
|
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
|
||||||
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
|
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
|
||||||
await act(async () => summary.click());
|
await act(async () => summary.click());
|
||||||
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/);
|
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./);
|
||||||
|
assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI");
|
||||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
||||||
await act(async () => render(null));
|
await act(async () => render(null));
|
||||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
|
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
|
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
|
||||||
|
import { ConnectionRow } from "../components/settings/connection-controls.tsx";
|
||||||
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
|
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
|
||||||
|
|
||||||
const dom = parseHTML("<html><body></body></html>");
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
@@ -33,6 +34,18 @@ test("row is informational, disclaims auth/billing readiness, and links only to
|
|||||||
assert.equal((html.match(/<button/g) ?? []).length, 1);
|
assert.equal((html.match(/<button/g) ?? []).length, 1);
|
||||||
assert(!html.includes("Connect</button>"));
|
assert(!html.includes("Connect</button>"));
|
||||||
assert(!html.includes("Sign in</button>"));
|
assert(!html.includes("Sign in</button>"));
|
||||||
|
assert.match(html, /data-variant="outline"/);
|
||||||
|
assert.match(html, /data-size="xs"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recheck matches the existing Connect button styling without restyling Manage actions", () => {
|
||||||
|
const row = (action: string) => renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />);
|
||||||
|
const connect = parseHTML(row("Connect")).document.querySelector("button")!;
|
||||||
|
const manage = parseHTML(row("Manage")).document.querySelector("button")!;
|
||||||
|
const check = parseHTML(renderToStaticMarkup(<ClaudeCodeInstallationStatus />)).document.querySelector("button")!;
|
||||||
|
assert.equal(check.getAttribute("class"), connect.getAttribute("class"));
|
||||||
|
assert.equal(check.getAttribute("data-variant"), "outline");
|
||||||
|
assert.equal(manage.getAttribute("data-variant"), "ghost");
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [value, text] of [
|
for (const [value, text] of [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain
|
|||||||
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
|
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
|
||||||
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
|
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
|
||||||
assert(!html.includes("$0"));
|
assert(!html.includes("$0"));
|
||||||
|
assert.match(html, /data-testid="stats-cost">CLI<\/span>/);
|
||||||
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
|
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
|
||||||
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
|
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
|
||||||
}] }], sources: [] }, callCost);
|
}] }], sources: [] }, callCost);
|
||||||
@@ -32,6 +33,18 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain
|
|||||||
assert.equal(summary.runs[0]!.dollars, null);
|
assert.equal(summary.runs[0]!.dollars, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => {
|
||||||
|
const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] };
|
||||||
|
const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" };
|
||||||
|
const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } };
|
||||||
|
const html = renderToStaticMarkup(<StatsBar turns={[api, cli]} pricing={pricing} />);
|
||||||
|
assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/);
|
||||||
|
const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" };
|
||||||
|
const mixed = renderToStaticMarkup(<StatsBar turns={[api, cli, subscription]} pricing={{ ...pricing, subscription: 1, subscriptionProviders: ["ChatGPT"] }} />);
|
||||||
|
assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/);
|
||||||
|
assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting");
|
||||||
|
});
|
||||||
|
|
||||||
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
|
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
|
||||||
const models = CLAUDE_CODE_MODELS.map(toModelDto);
|
const models = CLAUDE_CODE_MODELS.map(toModelDto);
|
||||||
assert(models.every((model) => model.cost === null));
|
assert(models.every((model) => model.cost === null));
|
||||||
|
|||||||
Reference in New Issue
Block a user