Skip to content
Open
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: 2 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
## Checklist

- [ ] `npm run verify` passes locally (lint + typecheck + tests + comment-policy gate)
- [ ] If desktop UI changed: `npm run verify:desktop` passes
- [ ] If desktop UI changed: buttons, popovers, sidebars, and chat auto-scroll were checked at narrow desktop widths
- [ ] No `Co-Authored-By: Claude` trailer in commits
- [ ] Comments follow CONTRIBUTING.md (no module-essay headers, no incident history)
- [ ] No edits to `CHANGELOG.md` — release notes are maintainer-written at release time
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,31 @@ jobs:
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: |
package-lock.json
desktop/package-lock.json

- name: Install dependencies
run: npm ci

- name: Install desktop dependencies
run: npm ci --prefix desktop

- name: Lint (biome)
run: npm run lint

- name: Typecheck
run: npm run typecheck

- name: Cache guard
run: npm run cache:guard

- name: Build (tsup + dashboard)
run: npm run build

- name: Build desktop
run: npm run build:desktop

- name: Test (vitest + coverage)
run: npm run test:coverage

Expand Down
19 changes: 18 additions & 1 deletion dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export type PendingRevision = {

export type UsageStats = {
totalCostUsd: number;
turnCostUsd: number;
totalPromptTokens: number;
totalCompletionTokens: number;
cacheHitTokens: number;
Expand Down Expand Up @@ -319,6 +320,13 @@ function nextMessageTurn(messages: ChatMessage[]): number {
return lastTurn + 1;
}

function isIncomingUserNewTurn(state: State, turn: number): boolean {
return (
!state.busy ||
!state.messages.some((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn)
);
}

function reduce(state: State, action: Action): State {
return withElidedTranscript(reduceRaw(state, action));
}
Expand All @@ -329,6 +337,7 @@ function reduceRaw(state: State, action: Action): State {
return {
...state,
busy: true,
usage: { ...state.usage, turnCostUsd: 0 },
messages: [
...state.messages,
{ kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) },
Expand All @@ -341,6 +350,7 @@ function reduceRaw(state: State, action: Action): State {
...state,
busy: true,
activeSkill: action.skill,
usage: { ...state.usage, turnCostUsd: 0 },
messages: [
...state.messages,
{
Expand Down Expand Up @@ -548,6 +558,7 @@ function mergeSessionFiles(existing: SessionFile[], adds: SessionFile[]): Sessio
function zeroUsage(): UsageStats {
return {
totalCostUsd: 0,
turnCostUsd: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
cacheHitTokens: 0,
Expand Down Expand Up @@ -581,16 +592,20 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State {
if (state.busy && last?.kind === "user" && last.text === ev.text) {
return state;
}
const turn = ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages);
return {
...state,
busy: true,
usage: isIncomingUserNewTurn(state, turn)
? { ...state.usage, turnCostUsd: 0 }
: state.usage,
messages: [
...state.messages,
{
kind: "user",
text: ev.text,
clientId: `remote-${ev.id}`,
turn: ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages),
turn,
},
],
};
Expand Down Expand Up @@ -751,6 +766,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State {
totalCompletionTokens: ev.totalCompletionTokens,
cacheHitTokens: ev.cacheHitTokens,
cacheMissTokens: ev.cacheMissTokens,
turnCostUsd: empty ? 0 : state.usage.turnCostUsd,
lastCallCacheHit: empty ? null : state.usage.lastCallCacheHit,
lastCallCacheMiss: empty ? null : state.usage.lastCallCacheMiss,
},
Expand Down Expand Up @@ -956,6 +972,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State {
const hasCall = callHit > 0 || callMiss > 0;
const usage: UsageStats = {
totalCostUsd: state.usage.totalCostUsd + (ev.costUsd ?? 0),
turnCostUsd: state.usage.turnCostUsd + (ev.costUsd ?? 0),
totalPromptTokens: state.usage.totalPromptTokens + (u?.prompt_tokens ?? 0),
totalCompletionTokens: state.usage.totalCompletionTokens + (u?.completion_tokens ?? 0),
cacheHitTokens: state.usage.cacheHitTokens + callHit,
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,7 @@ export const de: typeof en = {
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
thisTurn: "Dieser Turn",
session: "Sitzung",
tokens: "Tokens",
},
thread: {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@ export const en = {
themeStyleGlacier: "Glacier",
themeStyleMidnight: "Midnight",
thisTurn: "This turn",
session: "Session",
tokens: "Tokens",
},
thread: {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,7 @@ export const zhCN = {
themeStyleGlacier: "冰川",
themeStyleMidnight: "午夜",
thisTurn: "本轮",
session: "会话",
tokens: "Tokens",
},
thread: {
Expand Down
10 changes: 8 additions & 2 deletions dashboard/src/ui/statusbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export function StatusBar({
? `${usage.cacheHitTokens.toLocaleString()} / ${totalTokens.toLocaleString()} tokens (${cacheHitPctDisplay}%)`
: "";
const runningJobs = jobs.filter((j) => j.running).length;
const spent = formatMoney(usage.totalCostUsd, currency);
const turnSpent = formatMoney(usage.turnCostUsd, currency);
const sessionSpent = formatMoney(usage.totalCostUsd, currency);
const balanceLabel = balance
? `${balance.currency === "USD" ? "$" : "¥"} ${balance.total.toFixed(2)}`
: "—";
Expand Down Expand Up @@ -113,7 +114,12 @@ export function StatusBar({
<span className="seg">
<I.coin size={11} />
<span>{t("statusbar.thisTurn")}</span>
<span className="v ok">{spent}</span>
<span className="v ok">{turnSpent}</span>
</span>
<span className="seg">
<I.coin size={11} />
<span>{t("statusbar.session")}</span>
<span className="v ok">{sessionSpent}</span>
</span>

<span className="grow" />
Expand Down
73 changes: 63 additions & 10 deletions desktop/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function initialState(): Parameters<typeof reduce>[0] {
activePlan: null,
usage: {
totalCostUsd: 0,
turnCostUsd: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
cacheHitTokens: 0,
Expand Down Expand Up @@ -136,7 +137,7 @@ function makePathPrompt(
}

describe("Desktop App reducer — usage", () => {
it("falls back prompt tokens to cache miss tokens when cache fields are absent", () => {
it("falls back prompt tokens to cache miss tokens and tracks turn cost", () => {
const next = reduce(initialState(), {
t: "incoming",
event: {
Expand All @@ -151,9 +152,61 @@ describe("Desktop App reducer — usage", () => {
});

expect(next.usage.totalPromptTokens).toBe(1234);
expect(next.usage.turnCostUsd).toBe(0.001);
expect(next.usage.cacheHitTokens).toBe(0);
expect(next.usage.cacheMissTokens).toBe(1234);
expect(next.usage.lastCallCacheMiss).toBe(1234);

const accumulated = reduce(next, {
t: "incoming",
event: {
type: "model.final",
id: 2,
ts: "2026-05-27T00:00:01.000Z",
turn: 1,
content: "ok again",
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
costUsd: 0.002,
},
});
expect(accumulated.usage.turnCostUsd).toBeCloseTo(0.003, 6);

const steered = reduce(
{
...accumulated,
busy: true,
messages: [{ kind: "assistant", turn: 1, segments: [], pending: false }],
},
{
t: "incoming",
event: {
type: "user.message",
id: 3,
ts: "2026-05-27T00:00:02.000Z",
turn: 1,
text: "same-turn steer",
},
},
);
expect(steered.usage.turnCostUsd).toBeCloseTo(0.003, 6);

const reset = reduce(accumulated, {
t: "incoming",
event: {
type: "user.message",
id: 4,
ts: "2026-05-27T00:00:03.000Z",
turn: 2,
text: "next turn",
},
});
expect(reset.usage.turnCostUsd).toBe(0);

const localReset = reduce(
{ ...accumulated, usage: { ...accumulated.usage, turnCostUsd: 0.004 } },
{ t: "send_user", text: "local next turn", clientId: "local-1" },
);
expect(localReset.usage.turnCostUsd).toBe(0);
});

it("settles the pending assistant message when an error ends the turn (#1660)", () => {
Expand Down Expand Up @@ -396,14 +449,14 @@ describe("desktop thread layout", () => {
const side = 244;
const ctx = 320;

expect(
getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx }),
).toBe(580);
expect(
getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx }),
).toBe(756);
expect(
getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx }),
).toBe(1120);
expect(getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx })).toBe(
580,
);
expect(getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx })).toBe(
756,
);
expect(getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx })).toBe(
1120,
);
});
});
Loading
Loading