From a2980dc56b1552a00b1bfd45424e2ca0b377c5bb Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Wed, 6 May 2026 21:14:54 +0200 Subject: [PATCH 01/23] fix(api): expand ~/ in project root path when adding sources Users typing ~/projects/foo in the add-project form got a 'directory does not exist' error because Node's fs.existsSync doesn't expand tilde. The new expandTilde() helper (reusing the same pattern from XDG path handling) resolves ~ to os.homedir() before validation and storage. --- src/ingest/paths.ts | 9 +++++++++ src/server/api.ts | 8 +++++--- src/ui/components/AddProjectForm.tsx | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ingest/paths.ts b/src/ingest/paths.ts index e3e078c..1d96608 100644 --- a/src/ingest/paths.ts +++ b/src/ingest/paths.ts @@ -4,6 +4,15 @@ import * as path from "node:path" export type Env = Record +export function expandTilde(p: string, hd?: string): string { + const home = hd ?? os.homedir() + if (p === "~") return home + if (p.startsWith("~/") || p.startsWith("~\\")) { + return path.join(home, p.slice(2).replace(/[\\/]+/g, path.sep)) + } + return p +} + export function getDataDir(env: Env = process.env, homedir: string = os.homedir()): string { // Match oh-my-opencode behavior exactly: // XDG_DATA_HOME or ~/.local/share on all platforms. diff --git a/src/server/api.ts b/src/server/api.ts index 3eed73e..ba0dc97 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs" import { homedir } from "node:os" import { listSources, getDefaultSourceId, addOrUpdateSource, updateSourceLabelById, deleteSourceById } from "../ingest/sources-registry" import { getStorageRoots, getMessageDir } from "../ingest/session" -import { assertAllowedPath } from "../ingest/paths" +import { assertAllowedPath, expandTilde } from "../ingest/paths" import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "../ingest/tool-calls" import { deriveToolCallsSqlite } from "../ingest/sqlite-derive" import type { StorageBackend } from "../ingest/storage-backend" @@ -75,11 +75,13 @@ export function createApi(opts: { return c.json({ ok: false, error: "projectRoot is required and must be a non-empty string" }, 400) } - if (!fs.existsSync(projectRoot)) { + const resolvedRoot = expandTilde(projectRoot.trim()) + + if (!fs.existsSync(resolvedRoot)) { return c.json({ ok: false, error: "projectRoot directory does not exist" }, 400) } - const sourceId = addOrUpdateSource(opts.storageRoot, projectRoot, label) + const sourceId = addOrUpdateSource(opts.storageRoot, resolvedRoot, label) invalidateProjects() return c.json({ ok: true, sourceId }) }) diff --git a/src/ui/components/AddProjectForm.tsx b/src/ui/components/AddProjectForm.tsx index 6ef281e..664f61c 100644 --- a/src/ui/components/AddProjectForm.tsx +++ b/src/ui/components/AddProjectForm.tsx @@ -89,7 +89,7 @@ export function AddProjectForm({ onProjectAdded }: AddProjectFormProps) { className={`add-project-input add-project-input--path${statusType === "error" && !projectRoot.trim() ? " add-project-input--error" : ""}`} type="text" name="projectRoot" - placeholder="/path/to/project" + placeholder="~/projects/my-project" value={projectRoot} onChange={(e) => setProjectRoot(e.target.value)} disabled={loading} From 9b3ec09f5a391ef9754db773dcf2ed7adb27d56b Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 10 May 2026 12:52:46 +0200 Subject: [PATCH 02/23] fix(ingest): detect active question tools awaiting input - Add isActiveQuestionTool() for known question tools in pending|running - Use it in main-session, inclusion, and background-task derivation - Keep isPendingQuestionTool() pending-only for stale suppression - Add regressions for file-based, SQLite, and sound-diff paths --- src/__tests__/background-tasks-derive.test.ts | 94 +++++++++++++++++++ src/__tests__/question-bridge-sqlite.test.ts | 66 ++++++++++++- src/__tests__/session-diff.test.ts | 45 +++++++++ src/__tests__/session-inclusion.test.ts | 38 +++++++- .../session-status-derivation.test.ts | 33 ++++++- src/ingest/background-tasks.ts | 34 +++---- src/ingest/session-inclusion.ts | 54 ++++------- src/ingest/session-status.ts | 6 +- src/ingest/sqlite-derive.ts | 29 +++--- src/ingest/tool-names.ts | 8 ++ 10 files changed, 326 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/session-diff.test.ts diff --git a/src/__tests__/background-tasks-derive.test.ts b/src/__tests__/background-tasks-derive.test.ts index 30a23d6..34d380e 100644 --- a/src/__tests__/background-tasks-derive.test.ts +++ b/src/__tests__/background-tasks-derive.test.ts @@ -267,6 +267,100 @@ describe("deriveBackgroundTasksSqlite", () => { expect(result.value[0].timeline).not.toBe(""); }); + it("marks linked background task as question when child session has a running canonical question tool", () => { + const nowMs = 1_000_000; + const db = createMockDb({ + sessionRows: [ + { + id: "ses-main", + project_id: "proj-1", + directory: "/tmp/project", + time_created: nowMs - 10_000, + time_updated: nowMs - 1_000, + }, + { + id: "ses-child", + project_id: "proj-1", + parent_id: "ses-main", + directory: "/tmp/project", + title: "Background: Ask user", + time_created: nowMs - 800, + time_updated: nowMs - 500, + }, + ], + messagesBySession: { + "ses-main": [ + makeMessageRow({ + id: "msg-main", + sessionId: "ses-main", + createdAt: nowMs - 1_000, + agent: "sisyphus", + }), + ], + "ses-child": [ + makeMessageRow({ + id: "msg-child", + sessionId: "ses-child", + createdAt: nowMs - 500, + agent: "atlas", + }), + ], + }, + partsByMessage: { + "msg-main": [ + makePartRow({ + id: "part-main", + messageId: "msg-main", + sessionId: "ses-main", + createdAt: nowMs - 1_000, + callId: "call-bg-question", + tool: "background_task", + status: "completed", + input: { + description: "Ask user", + run_in_background: true, + subagent_type: "atlas", + }, + metadata: { sessionId: "ses-child" }, + startAt: nowMs - 2_000, + }), + ], + "msg-child": [ + makePartRow({ + id: "part-child-question", + messageId: "msg-child", + sessionId: "ses-child", + createdAt: nowMs - 500, + callId: "child-question", + tool: "question", + status: "running", + }), + ], + }, + }); + + const result = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs, + db: db as unknown as Database, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toHaveLength(1); + expect(result.value[0]).toMatchObject({ + id: "call-bg-question", + description: "Ask user", + agent: "atlas", + status: "question", + toolCalls: 1, + lastTool: "question", + sessionId: "ses-child", + }); + }); + it("marks stale unlinked background task as unknown with null toolCalls", () => { const nowMs = 2_000_000; const startedAt = nowMs - 16 * 60_000; diff --git a/src/__tests__/question-bridge-sqlite.test.ts b/src/__tests__/question-bridge-sqlite.test.ts index ace6f54..edd5a8d 100644 --- a/src/__tests__/question-bridge-sqlite.test.ts +++ b/src/__tests__/question-bridge-sqlite.test.ts @@ -73,12 +73,12 @@ vi.mock("../ingest/storage-backend", () => ({ readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta, childSessionMeta] })), readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [{ id: "ses-child" }] })), readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), - readRecentMessageMetasSqlite: vi.fn(function ({ sessionId }: { sessionId: string }) { + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { if (sessionId === "ses-main") return { ok: true as const, rows: [mainMeta] } if (sessionId === "ses-child") return { ok: true as const, rows: [childMeta] } return { ok: true as const, rows: [] } }), - readToolPartsForMessagesSqlite: vi.fn(function ({ messageIds }: { messageIds: string[] }) { + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { const rows: StoredToolPart[] = [] if (messageIds.includes("msg-main")) rows.push(mainTaskPart) if (messageIds.includes("msg-child")) rows.push(childQuestionPart) @@ -118,4 +118,66 @@ describe("background question bridge (SQLite)", () => { expect(viewResult.value.status).toBe("question") expect(viewResult.value.currentTool).toBe("mcp_question") }) + + it("surfaces question for SQLite background tasks when child question tool is running", async () => { + vi.doMock("../ingest/storage-backend", () => { + const runningChildQuestionPart: StoredToolPart = { + ...childQuestionPart, + tool: "question", + state: { + ...childQuestionPart.state, + status: "running", + }, + } + + return { + readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta, childSessionMeta] })), + readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [{ id: "ses-child" }] })), + readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { + if (sessionId === "ses-main") return { ok: true as const, rows: [mainMeta] } + if (sessionId === "ses-child") return { ok: true as const, rows: [childMeta] } + return { ok: true as const, rows: [] } + }), + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { + const rows: StoredToolPart[] = [] + if (messageIds.includes("msg-main")) rows.push(mainTaskPart) + if (messageIds.includes("msg-child")) rows.push(runningChildQuestionPart) + return { ok: true as const, rows } + }), + } + }) + + vi.resetModules() + const { deriveBackgroundTasksSqlite, getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const tasksResult = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs: 1_000_000, + }) + + expect(tasksResult.ok).toBe(true) + if (!tasksResult.ok) throw new Error("expected sqlite background tasks") + expect(tasksResult.value[0]?.status).toBe("question") + expect(tasksResult.value[0]?.lastTool).toBe("question") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: 900_000, updated: 999_000 }, + }, + nowMs: 1_000_000, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).toBe("question") + expect(viewResult.value.currentTool).toBe("question") + }) }) diff --git a/src/__tests__/session-diff.test.ts b/src/__tests__/session-diff.test.ts new file mode 100644 index 0000000..080e26a --- /dev/null +++ b/src/__tests__/session-diff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { buildSessionStatusMap, diffSessionStatuses, shouldPlaySound } from "../ingest/session-diff" +import type { SessionSummary, SoundConfig } from "../types" + +const SOUND_CONFIG: SoundConfig = { + enabled: true, + volume: 0.8, + onSessionIdle: true, + onPlanComplete: true, + onSessionError: true, + onQuestion: true, +} + +function makeSessionSummary(sessionId: string, status: SessionSummary["status"]): SessionSummary { + return { + sessionId, + sessionLabel: sessionId, + agent: "build", + status, + currentModel: "gpt-5.4", + currentTool: status === "question" ? "question" : "bash", + lastUpdated: "2026-05-10T00:00:00.000Z", + lastUpdatedMs: 1_000_000, + } +} + +describe("session-diff question sound", () => { + it("plays the question sound when a session transitions into question status", () => { + const previous = buildSessionStatusMap([ + makeSessionSummary("ses-main", "running_tool"), + ]) + const current = buildSessionStatusMap([ + makeSessionSummary("ses-main", "question"), + ]) + + const diff = diffSessionStatuses(previous, current) + const playback = shouldPlaySound(diff, SOUND_CONFIG) + + expect(playback.playQuestion).toBe(true) + expect(playback.playAttention).toBe(false) + expect(playback.playWaiting).toBe(false) + expect(playback.playAllClear).toBe(false) + }) +}) diff --git a/src/__tests__/session-inclusion.test.ts b/src/__tests__/session-inclusion.test.ts index 82815dc..0f17187 100644 --- a/src/__tests__/session-inclusion.test.ts +++ b/src/__tests__/session-inclusion.test.ts @@ -1,13 +1,12 @@ import type { Database } from "bun:sqlite" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { SessionMetadata } from "../ingest/session" +import { findIncludedSessionsSqlite, isSessionIncluded } from "../ingest/session-inclusion" vi.mock("../ingest/paths", () => ({ realpathSafe: vi.fn((p: string) => p), })) -import { isSessionIncluded, findIncludedSessionsSqlite } from "../ingest/session-inclusion" -import type { SessionMetadata } from "../ingest/session" - type SessionRow = { id: string title?: string @@ -422,6 +421,37 @@ describe("findIncludedSessionsSqlite", () => { expect(result).toEqual([]) }) + it("orders fresh running canonical question tools ahead of busy sessions", () => { + const now = Date.now() + const result = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "question-session", + title: "Question", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 1_000, + }, + { + id: "busy-session", + title: "Busy", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 500, + }, + ], + activePartsBySession: { + "question-session": [{ tool: "question", status: "running" }], + }, + }), + "/home/user/project", + 60000, + ) + + expect(result.map((session) => session.id)).toEqual(["question-session", "busy-session"]) + }) + it("handles mixed sessions: active top-level, stale excluded, child/background excluded", () => { const now = Date.now() const result = runFindIncludedSessionsSqlite( diff --git a/src/__tests__/session-status-derivation.test.ts b/src/__tests__/session-status-derivation.test.ts index 75d46e1..b6f5f12 100644 --- a/src/__tests__/session-status-derivation.test.ts +++ b/src/__tests__/session-status-derivation.test.ts @@ -286,8 +286,8 @@ describe("status derivation characterization: SQLite session inclusion path", () "ses-running", "ses-thinking", "ses-busy-fresh", - "ses-busy", "ses-unknown", + "ses-busy", "ses-idle", ]); }); @@ -355,6 +355,37 @@ describe("status derivation characterization: file-based getMainSessionView path expect(view.status).toBe("question"); }); + it("returns question when the latest active tool is a running canonical question tool", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-question-running", + "ses-question-running", + NOW_MS - 5_000, + NOW_MS - 4_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-question-running", + tool: "question", + state: { status: "running", input: {} }, + }); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 5_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("question"); + }); + it("returns running_tool when the latest active tool is non-question", () => { const storage = makeTempStorage(); const message = makeAssistantMessage( diff --git a/src/ingest/background-tasks.ts b/src/ingest/background-tasks.ts index 327e943..bd06298 100644 --- a/src/ingest/background-tasks.ts +++ b/src/ingest/background-tasks.ts @@ -1,14 +1,14 @@ import * as fs from "node:fs" import * as path from "node:path" import { BACKGROUND_RUNNING_WINDOW_MS, shouldKeepQueuedBackgroundTaskActive } from "./activity-status" -import { formatElapsed, formatIsoNoMs, formatTimeline } from "./format-utils" -import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" -import { getMessageDir } from "./paths" +import { formatTimeline } from "./format-utils" import { pickLatestModelString } from "./model" -import { isPendingQuestionTool } from "./tool-names" +import { getMessageDir } from "./paths" +import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" +import { isActiveQuestionTool } from "./tool-names" -type FsLike = Pick +type FsLike = Pick const RECENT_MESSAGES_LIMIT = 200 @@ -171,7 +171,7 @@ function deriveBackgroundSessionStats( storage: OpenCodeStorageRoots, metas: StoredMessageMeta[], fsLike: FsLike -): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null; pendingQuestionTool: string | null } { +): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null; activeQuestionTool: string | null } { let toolCalls = 0 let lastTool: string | null = null let lastUpdateAt: number | null = null @@ -183,17 +183,17 @@ function deriveBackgroundSessionStats( return String(b.id).localeCompare(String(a.id)) }) - let pendingQuestionTool: string | null = null + let activeQuestionTool: string | null = null for (const meta of newestFirst) { const parts = readToolPartsForMessage(storage, meta.id, fsLike) for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] - if (isPendingQuestionTool(part.tool, part.state.status)) { - pendingQuestionTool = part.tool + if (isActiveQuestionTool(part.tool, part.state.status)) { + activeQuestionTool = part.tool break } } - if (pendingQuestionTool) break + if (activeQuestionTool) break } // Deterministic ordering by time.created then id. @@ -214,7 +214,7 @@ function deriveBackgroundSessionStats( } } - return { toolCalls, lastTool, lastUpdateAt, pendingQuestionTool } + return { toolCalls, lastTool, lastUpdateAt, activeQuestionTool } } const TASK_TOOL_NAMES = new Set(["delegate_task", "task", "call_omo_agent", "background_task"]) @@ -285,7 +285,7 @@ function deriveBackgroundTaskStatusFiles(opts: { backgroundSessionId: string | null toolCalls: number lastUpdateAt: number | null - pendingQuestionTool: string | null + activeQuestionTool: string | null startedAt: number nowMs: number }): BackgroundTaskRow["status"] { @@ -295,7 +295,7 @@ function deriveBackgroundTaskStatusFiles(opts: { if (opts.toolCalls === 0 && opts.lastUpdateAt === null) { return shouldKeepQueuedBackgroundTaskActive(opts.startedAt, opts.nowMs) ? "queued" : "unknown" } - if (opts.pendingQuestionTool) return "question" + if (opts.activeQuestionTool) return "question" if (opts.lastUpdateAt && opts.nowMs - opts.lastUpdateAt <= BACKGROUND_RUNNING_WINDOW_MS) return "running" if (opts.toolCalls > 0) return "completed" return "unknown" @@ -314,7 +314,7 @@ export function deriveBackgroundTasks(opts: { const allSessionMetas = readAllSessionMetas(opts.storage.session, fsLike) const sessionMetaById = new Map(allSessionMetas.map((m) => [m.id, m] as const)) const backgroundMessageCache = new Map() - const backgroundStatsCache = new Map() + const backgroundStatsCache = new Map() const backgroundModelCache = new Map() const readBackgroundMetas = (sessionId: string): StoredMessageMeta[] => { @@ -405,14 +405,14 @@ export function deriveBackgroundTasks(opts: { const stats = backgroundSessionId ? readBackgroundStats(backgroundSessionId) - : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt, pendingQuestionTool: null } + : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt, activeQuestionTool: null } const lastModel = backgroundSessionId ? readBackgroundModel(backgroundSessionId) : null const status = deriveBackgroundTaskStatusFiles({ backgroundSessionId, toolCalls: stats.toolCalls, lastUpdateAt: stats.lastUpdateAt, - pendingQuestionTool: stats.pendingQuestionTool, + activeQuestionTool: stats.activeQuestionTool, startedAt, nowMs, }) @@ -425,7 +425,7 @@ export function deriveBackgroundTasks(opts: { agent, status, toolCalls: backgroundSessionId ? stats.toolCalls : null, - lastTool: stats.pendingQuestionTool ?? stats.lastTool, + lastTool: stats.activeQuestionTool ?? stats.lastTool, lastModel, timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs), sessionId: backgroundSessionId, diff --git a/src/ingest/session-inclusion.ts b/src/ingest/session-inclusion.ts index b16da83..a18d2f0 100644 --- a/src/ingest/session-inclusion.ts +++ b/src/ingest/session-inclusion.ts @@ -1,9 +1,9 @@ -import * as path from "node:path" import type { Database } from "bun:sqlite" -import { realpathSafe } from "./paths" +import * as path from "node:path" import { ACTIVE_BUSY_WINDOW_MS, hasFreshMainSessionActivity, shouldSuppressStaleToolActivity } from "./activity-status" +import { realpathSafe } from "./paths" import type { SessionMetadata } from "./session" -import { isPendingQuestionTool } from "./tool-names" +import { isActiveQuestionTool } from "./tool-names" // Severity levels for attention-first ordering const STATUS_SEVERITY: Record = { @@ -31,7 +31,6 @@ function deriveSessionStatusFromMaps( lastUpdated: number, nowMs: number, activePartsMap: Map>, - terminalPartsMap: Map>, assistantMsgsMap: Map>, ): string { const activeParts = activePartsMap.get(sessionId) ?? [] @@ -41,12 +40,10 @@ function deriveSessionStatusFromMaps( if (activeParts.length > 0) { const activePart = activeParts[0] if (!shouldSuppressStaleToolActivity(activePart.tool, activePart.status, hasFreshActivity)) { - return isPendingQuestionTool(activePart.tool, activePart.status) ? "question" : "running_tool" + return isActiveQuestionTool(activePart.tool, activePart.status) ? "question" : "running_tool" } } - const isStaleActivity = ageMs > ACTIVE_BUSY_WINDOW_MS - const recentMessages = assistantMsgsMap.get(sessionId) ?? [] if (recentMessages.length > 0 && recentMessages[0].time_completed === null) { @@ -128,34 +125,15 @@ export function findIncludedSessionsSqlite( ) .all(...candidateIds) as Array<{ session_id: string; tool: string; status: string }> for (const row of activeRows) { - if (!activePartsMap.has(row.session_id)) { - activePartsMap.set(row.session_id, []) - } - if ((activePartsMap.get(row.session_id)?.length ?? 0) < 1) { - activePartsMap.get(row.session_id)!.push({ tool: row.tool, status: row.status }) + const sessionActiveParts = activePartsMap.get(row.session_id) + if (!sessionActiveParts) { + activePartsMap.set(row.session_id, [{ tool: row.tool, status: row.status }]) + } else if (sessionActiveParts.length < 1) { + sessionActiveParts.push({ tool: row.tool, status: row.status }) } } - // Batch query 2: terminal parts (error/completed) - const terminalPartsMap = new Map>() - const terminalRows = db - .query( - `SELECT session_id, time_created, json_extract(data, '$.state.status') as status - FROM part - WHERE session_id IN (${placeholders}) AND json_extract(data, '$.state.status') IN ('error', 'completed') - ORDER BY time_created DESC` - ) - .all(...candidateIds) as Array<{ session_id: string; time_created: number; status: string }> - for (const row of terminalRows) { - if (!terminalPartsMap.has(row.session_id)) { - terminalPartsMap.set(row.session_id, []) - } - if ((terminalPartsMap.get(row.session_id)?.length ?? 0) < 1) { - terminalPartsMap.get(row.session_id)!.push({ time_created: row.time_created, status: row.status }) - } - } - - // Batch query 3: recent assistant messages + // Batch query 2: recent assistant messages const assistantMsgsMap = new Map>() const assistantRows = db .query( @@ -166,11 +144,11 @@ export function findIncludedSessionsSqlite( ) .all(...candidateIds) as Array<{ session_id: string; time_completed: number | null }> for (const row of assistantRows) { - if (!assistantMsgsMap.has(row.session_id)) { - assistantMsgsMap.set(row.session_id, []) - } - if ((assistantMsgsMap.get(row.session_id)?.length ?? 0) < 1) { - assistantMsgsMap.get(row.session_id)!.push({ time_completed: row.time_completed }) + const sessionAssistantMsgs = assistantMsgsMap.get(row.session_id) + if (!sessionAssistantMsgs) { + assistantMsgsMap.set(row.session_id, [{ time_completed: row.time_completed }]) + } else if (sessionAssistantMsgs.length < 1) { + sessionAssistantMsgs.push({ time_completed: row.time_completed }) } } @@ -181,7 +159,7 @@ export function findIncludedSessionsSqlite( const lastUpdated = meta.time.updated ?? meta.time.created ?? 0 const status = deriveSessionStatusFromMaps( meta.id, lastUpdated, nowMs, - activePartsMap, terminalPartsMap, assistantMsgsMap, + activePartsMap, assistantMsgsMap, ) if ( isSessionIncluded(meta, idleWindowMs, nowMs) || diff --git a/src/ingest/session-status.ts b/src/ingest/session-status.ts index 1c074ac..72eec7f 100644 --- a/src/ingest/session-status.ts +++ b/src/ingest/session-status.ts @@ -1,6 +1,6 @@ import { ACTIVE_BUSY_WINDOW_MS, shouldSuppressStaleToolActivity } from "./activity-status" -import { isPendingQuestionTool } from "./tool-names" import type { MainSessionView } from "./session" +import { isActiveQuestionTool } from "./tool-names" export type ActiveToolRef = { tool: string; status: string } | null @@ -32,8 +32,6 @@ export function deriveMainSessionStatus(opts: DeriveMainSessionStatusOpts): Deri const { hasFreshActivity, isStaleActivity, - latestTerminalStatus, - latestTerminalAt, recentRole, recentTimeCreated, recentTimeCompleted, @@ -47,7 +45,7 @@ export function deriveMainSessionStatus(opts: DeriveMainSessionStatusOpts): Deri if (shouldSuppressStaleToolActivity(activeTool.tool, activeTool.status, hasFreshActivity)) { activeTool = null } else { - status = isPendingQuestionTool(activeTool.tool, activeTool.status) ? "question" : "running_tool" + status = isActiveQuestionTool(activeTool.tool, activeTool.status) ? "question" : "running_tool" } } diff --git a/src/ingest/sqlite-derive.ts b/src/ingest/sqlite-derive.ts index 4ad890b..f693e4a 100644 --- a/src/ingest/sqlite-derive.ts +++ b/src/ingest/sqlite-derive.ts @@ -6,9 +6,11 @@ import { shouldKeepQueuedBackgroundTaskActive, } from "./activity-status" import type { BackgroundTaskRow } from "./background-tasks" -import { canonicalizeAgent, formatElapsed, formatIsoNoMs, formatTimeline, normalizeSessionIds } from "./format-utils" +import { canonicalizeAgent, formatTimeline, normalizeSessionIds } from "./format-utils" import { pickLatestModelString } from "./model" import type { MainSessionView, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" +import { deriveMainSessionStatus } from "./session-status" +import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" import { readAllSessionMetasSqlite, readMainSessionMetasSqlite, @@ -20,12 +22,10 @@ import { type SqliteReadFailureReason, type TodoItem, } from "./storage-backend" -import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" +import type { TimeSeriesPayload, TimeSeriesSeries } from "./timeseries" import { aggregateTokenUsage } from "./token-usage-core" import { MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS, type ToolCallSummaryResult } from "./tool-calls" -import { isPendingQuestionTool, TASK_TOOL_NAMES } from "./tool-names" -import { deriveMainSessionStatus } from "./session-status" -import type { TimeSeriesPayload, TimeSeriesSeries } from "./timeseries" +import { isActiveQuestionTool, TASK_TOOL_NAMES } from "./tool-names" type SqliteDeriveResult = | { ok: true; value: T } @@ -157,7 +157,7 @@ function mapToolPartsByMessage(parts: StoredToolPart[]): Map, ): string | null { @@ -165,7 +165,7 @@ function findPendingQuestionTool( const parts = partsByMessage.get(meta.id) ?? [] for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] - if (isPendingQuestionTool(part.tool, part.state.status)) { + if (isActiveQuestionTool(part.tool, part.state.status)) { return part.tool } } @@ -467,7 +467,7 @@ function deriveBackgroundTaskStatus(opts: { backgroundSessionId: string | null toolCalls: number lastUpdateAt: number | null - pendingQuestionTool: string | null + activeQuestionTool: string | null startedAt: number nowMs: number }): BackgroundTaskRow["status"] { @@ -477,7 +477,7 @@ function deriveBackgroundTaskStatus(opts: { if (opts.toolCalls === 0 && opts.lastUpdateAt === null) { return shouldKeepQueuedBackgroundTaskActive(opts.startedAt, opts.nowMs) ? "queued" : "unknown" } - if (opts.pendingQuestionTool) return "question" + if (opts.activeQuestionTool) return "question" if (opts.lastUpdateAt && opts.nowMs - opts.lastUpdateAt <= BACKGROUND_RUNNING_WINDOW_MS) return "running" if (opts.toolCalls > 0) return "completed" return "unknown" @@ -580,14 +580,14 @@ export function deriveBackgroundTasksSqlite(opts: { const background = backgroundSessionId ? readBackgroundSession(backgroundSessionId) : null if (background && !background.ok) return background - const backgroundMetas = background && background.ok ? background.value.metas : [] - const backgroundPartsByMessage = background && background.ok ? background.value.partsByMessage : new Map() - const pendingQuestionTool = findPendingQuestionTool(backgroundMetas, backgroundPartsByMessage) + const backgroundMetas = background?.ok ? background.value.metas : [] + const backgroundPartsByMessage = background?.ok ? background.value.partsByMessage : new Map() + const activeQuestionTool = findActiveQuestionTool(backgroundMetas, backgroundPartsByMessage) const { toolCalls, lastTool, lastUpdateAt } = computeBackgroundTaskStatsSqlite(backgroundMetas, backgroundPartsByMessage) const lastModel = backgroundMetas.length > 0 ? pickLatestModelString(backgroundMetas) : null - const status = deriveBackgroundTaskStatus({ backgroundSessionId, toolCalls, lastUpdateAt, pendingQuestionTool, startedAt, nowMs }) + const status = deriveBackgroundTaskStatus({ backgroundSessionId, toolCalls, lastUpdateAt, activeQuestionTool, startedAt, nowMs }) const timelineEndMs = status === "completed" ? (lastUpdateAt ?? nowMs) : nowMs rows.push({ @@ -596,7 +596,7 @@ export function deriveBackgroundTasksSqlite(opts: { agent, status, toolCalls: backgroundSessionId ? toolCalls : null, - lastTool: pendingQuestionTool ?? lastTool, + lastTool: activeQuestionTool ?? lastTool, lastModel, timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs), sessionId: backgroundSessionId, @@ -944,7 +944,6 @@ export function deriveTodosSqliteForSessions(opts: { }) if (!result.ok) return result - const { sessionId: _, ...todoRest } = result.rows[0] ?? {} return { ok: true, value: result.rows.map(({ sessionId: _sid, ...item }) => item), diff --git a/src/ingest/tool-names.ts b/src/ingest/tool-names.ts index 458430f..231228a 100644 --- a/src/ingest/tool-names.ts +++ b/src/ingest/tool-names.ts @@ -6,6 +6,14 @@ export const QUESTION_TOOL_NAMES = new Set([ "mcp_question", ]) +export function isActiveQuestionTool(toolName: string | null | undefined, status: string | null | undefined): boolean { + return ( + typeof toolName === "string" && + (status === "pending" || status === "running") && + QUESTION_TOOL_NAMES.has(toolName) + ) +} + export function isPendingQuestionTool(toolName: string | null | undefined, status: string | null | undefined): boolean { return typeof toolName === "string" && status === "pending" && QUESTION_TOOL_NAMES.has(toolName) } From 7ef4da6ae344ee664fcf81940ff733005baee36e Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 17 May 2026 00:19:39 +0200 Subject: [PATCH 03/23] fix(ingest): add stale question age guard --- src/__tests__/activity-status.test.ts | 13 +++++++++++++ src/ingest/activity-status.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/__tests__/activity-status.test.ts b/src/__tests__/activity-status.test.ts index 7f159ae..1f47336 100644 --- a/src/__tests__/activity-status.test.ts +++ b/src/__tests__/activity-status.test.ts @@ -4,6 +4,7 @@ import { ACTIVE_STALE_MS, BACKGROUND_QUEUE_STALE_MS, hasFreshMainSessionActivity, + isStaleQuestionTool, resolveLastUpdatedTime, shouldSuppressStaleToolActivity, shouldKeepQueuedBackgroundTaskActive, @@ -66,4 +67,16 @@ describe("activity-status helpers", () => { it("suppresses stale running question tools", () => { expect(shouldSuppressStaleToolActivity("question", "running", false)).toBe(true) }) + + it("detects stale running question tools by tool start age", () => { + expect(isStaleQuestionTool("question", "running", 1_000, 1_000 + ACTIVE_STALE_MS + 1)).toBe(true) + }) + + it("keeps fresh running question tools active", () => { + expect(isStaleQuestionTool("question", "running", 1_000, 1_000 + ACTIVE_STALE_MS - 1)).toBe(false) + }) + + it("never treats pending question tools as stale", () => { + expect(isStaleQuestionTool("mcp_question", "pending", 1_000, 1_000 + ACTIVE_STALE_MS + 1)).toBe(false) + }) }) diff --git a/src/ingest/activity-status.ts b/src/ingest/activity-status.ts index 2996596..26db5af 100644 --- a/src/ingest/activity-status.ts +++ b/src/ingest/activity-status.ts @@ -14,6 +14,33 @@ export function shouldKeepQueuedBackgroundTaskActive(startedAt: number, nowMs: n return nowMs - startedAt <= BACKGROUND_QUEUE_STALE_MS } +/** + * Returns true if a question tool with 'running' status started too long ago + * to be considered active. Pending questions are never stale (they wait in queue). + * Fresh running questions (within ACTIVE_STALE_MS) are preserved. + */ +export function isStaleQuestionTool( + toolName: string, + status: string, + toolStartedAt: number | null, + nowMs: number, +): boolean { + if (status !== "running") return false + if (!QUESTION_TOOL_NAMES.has(toolName)) return false + if (typeof toolStartedAt !== "number" || !Number.isFinite(toolStartedAt)) return false + return nowMs - toolStartedAt > ACTIVE_STALE_MS +} + +export function readToolStartTime(toolPart: unknown): number | null { + if (!toolPart || typeof toolPart !== "object") return null + const state = (toolPart as Record).state + if (!state || typeof state !== "object") return null + const time = (state as Record).time + if (!time || typeof time !== "object") return null + const start = (time as Record).start + return typeof start === "number" && Number.isFinite(start) ? start : null +} + export function resolveLastUpdatedTime(primary: number | null, fallback: number | null): number | null { if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary if (typeof fallback === "number" && Number.isFinite(fallback) && fallback > 0) return fallback From e8f28ab8ef24b0e87510e6884462ea0001020af6 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 17 May 2026 00:19:39 +0200 Subject: [PATCH 04/23] fix(ingest): skip stale main question tools --- src/__tests__/question-bridge-sqlite.test.ts | 125 ++++++++++++++ .../session-status-derivation.test.ts | 163 ++++++++++++++++++ src/ingest/session.ts | 19 +- src/ingest/sqlite-derive.ts | 10 +- 4 files changed, 310 insertions(+), 7 deletions(-) diff --git a/src/__tests__/question-bridge-sqlite.test.ts b/src/__tests__/question-bridge-sqlite.test.ts index edd5a8d..a879c5f 100644 --- a/src/__tests__/question-bridge-sqlite.test.ts +++ b/src/__tests__/question-bridge-sqlite.test.ts @@ -180,4 +180,129 @@ describe("background question bridge (SQLite)", () => { expect(viewResult.value.status).toBe("question") expect(viewResult.value.currentTool).toBe("question") }) + + it("does not surface question for stale orphaned SQLite running question tools", async () => { + const nowMs = 2_000_000 + const freshMainMeta: StoredMessageMeta = { + ...mainMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + const freshChildMeta: StoredMessageMeta = { + ...childMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + const staleRunningQuestionPart: StoredToolPart = { + ...childQuestionPart, + tool: "question", + state: { + ...childQuestionPart.state, + status: "running", + time: { start: nowMs - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + } + + vi.doMock("../ingest/storage-backend", () => ({ + readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta, childSessionMeta] })), + readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [{ id: "ses-child" }] })), + readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { + if (sessionId === "ses-main") return { ok: true as const, rows: [freshMainMeta] } + if (sessionId === "ses-child") return { ok: true as const, rows: [freshChildMeta] } + return { ok: true as const, rows: [] } + }), + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { + const rows: StoredToolPart[] = [] + if (messageIds.includes("msg-main")) rows.push(mainTaskPart) + if (messageIds.includes("msg-child")) rows.push(staleRunningQuestionPart) + return { ok: true as const, rows } + }), + })) + + vi.resetModules() + const { deriveBackgroundTasksSqlite, getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const tasksResult = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs, + }) + + expect(tasksResult.ok).toBe(true) + if (!tasksResult.ok) throw new Error("expected sqlite background tasks") + expect(tasksResult.value[0]?.status).not.toBe("question") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: nowMs - 10_000, updated: nowMs - 1_000 }, + }, + nowMs, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).not.toBe("question") + }) + + it("does not surface question for stale orphaned SQLite main-session running question tools", async () => { + const nowMs = 2_000_000 + const staleMainQuestionPart: StoredToolPart = { + id: "part-main-stale-question", + sessionID: "ses-main", + messageID: "msg-main", + type: "tool", + callID: "call-main-stale-question", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: nowMs - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + } + const freshMainMeta: StoredMessageMeta = { + ...mainMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + + vi.doMock("../ingest/storage-backend", () => ({ + readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { + if (sessionId === "ses-main") return { ok: true as const, rows: [freshMainMeta] } + return { ok: true as const, rows: [] } + }), + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { + const rows: StoredToolPart[] = [] + if (messageIds.includes("msg-main")) rows.push(staleMainQuestionPart) + return { ok: true as const, rows } + }), + })) + + vi.resetModules() + const { getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: nowMs - 10_000, updated: nowMs - 1_000 }, + }, + nowMs, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).not.toBe("question") + expect(viewResult.value.currentTool).toBeNull() + }) }) diff --git a/src/__tests__/session-status-derivation.test.ts b/src/__tests__/session-status-derivation.test.ts index b6f5f12..3fc301e 100644 --- a/src/__tests__/session-status-derivation.test.ts +++ b/src/__tests__/session-status-derivation.test.ts @@ -33,6 +33,8 @@ type SessionRow = { type ActivePartRow = { tool: string; status?: string; + started_at?: number | null; + time_created?: number | null; }; type TerminalPartRow = { @@ -321,6 +323,85 @@ describe("status derivation characterization: SQLite session inclusion path", () "ses-stale", ]); }); + + it("does not include a fresh session solely for a stale running question tool", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-stale-question", + directory: PROJECT_ROOT, + time_created: age(30_000), + time_updated: age(30_000), + }, + ], + activePartsBySession: { + "ses-stale-question": [ + { tool: "question", status: "running", started_at: age(700_000), time_created: age(700_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual([]); + }); + + it("keeps fresh running question tools included as attention", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-fresh-question", + directory: PROJECT_ROOT, + time_created: age(700_000), + time_updated: age(700_000), + }, + ], + activePartsBySession: { + "ses-fresh-question": [ + { tool: "question", status: "running", started_at: age(1_000), time_created: age(1_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual(["ses-fresh-question"]); + }); + + it("keeps scanning SQLite active parts after a stale running question candidate", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-masked-question", + directory: PROJECT_ROOT, + time_created: age(700_000), + time_updated: age(700_000), + }, + ], + activePartsBySession: { + "ses-masked-question": [ + { tool: "question", status: "running", started_at: age(700_000), time_created: age(1_000) }, + { tool: "mcp_question", status: "pending", started_at: age(700_000), time_created: age(2_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual(["ses-masked-question"]); + }); }); describe("status derivation characterization: file-based getMainSessionView path", () => { @@ -386,6 +467,88 @@ describe("status derivation characterization: file-based getMainSessionView path expect(view.status).toBe("question"); }); + it("ignores stale orphaned running question tools in file-backed main sessions", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-stale-question-running", + "ses-stale-question-running", + NOW_MS - 30_000, + NOW_MS - 29_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-stale-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-stale-question-running", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: NOW_MS - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + }); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 30_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("busy"); + expect(view.currentTool).toBeNull(); + }); + + it("keeps scanning file-backed tool parts after a stale running question candidate", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-masked-question", + "ses-masked-question", + NOW_MS - 30_000, + NOW_MS - 29_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-pending-question", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-pending-question", + tool: "mcp_question", + state: { status: "pending", input: {} }, + }, "0001.json"); + + writeToolPart(storage, message.id, { + id: "part-stale-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-stale-question-running", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: NOW_MS - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + }, "0002.json"); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 30_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("question"); + expect(view.currentTool).toBe("mcp_question"); + }); + it("returns running_tool when the latest active tool is non-question", () => { const storage = makeTempStorage(); const message = makeAssistantMessage( diff --git a/src/ingest/session.ts b/src/ingest/session.ts index 52b8431..7f4dc45 100644 --- a/src/ingest/session.ts +++ b/src/ingest/session.ts @@ -2,6 +2,8 @@ import * as fs from "node:fs" import * as path from "node:path" import { hasFreshMainSessionActivity, + isStaleQuestionTool, + readToolStartTime, resolveLastUpdatedTime, } from "./activity-status" import { pickLatestModelString } from "./model" @@ -254,11 +256,12 @@ function readRecentMessageMetas(messageDir: string, maxMessages: number): Stored .map(item => item.meta) } -function readLastToolPart(partStorage: string, messageID: string): { tool: string; status: string } | null { +function readToolPartsForMessage(partStorage: string, messageID: string): Array<{ tool: string; status: string; startedAt: number | null }> { const partDir = path.join(partStorage, messageID) - if (!fs.existsSync(partDir)) return null + if (!fs.existsSync(partDir)) return [] const files = fs.readdirSync(partDir).filter((f) => f.endsWith(".json")).sort() + const parts: Array<{ tool: string; status: string; startedAt: number | null }> = [] for (let i = files.length - 1; i >= 0; i--) { const file = files[i] try { @@ -266,14 +269,14 @@ function readLastToolPart(partStorage: string, messageID: string): { tool: strin const part = JSON.parse(content) as Partial if (part.type === "tool" && typeof part.tool === "string") { const status = (part as StoredToolPart).state?.status - return { tool: part.tool, status: typeof status === "string" ? status : "unknown" } + parts.push({ tool: part.tool, status: typeof status === "string" ? status : "unknown", startedAt: readToolStartTime(part) }) } } catch { // Expected: file may not exist or be malformed continue } } - return null + return parts } function messageTerminalToolStatus(partStorage: string, messageID: string): "error" | "completed" | null { @@ -325,11 +328,15 @@ export function getMainSessionView(opts: { // Iterate newest -> oldest, early-exit on first tool part with pending/running status for (const meta of recentMetas) { - const toolPart = readLastToolPart(opts.storage.part, meta.id) - if (toolPart && (toolPart.status === "pending" || toolPart.status === "running")) { + for (const toolPart of readToolPartsForMessage(opts.storage.part, meta.id)) { + if (toolPart.status !== "pending" && toolPart.status !== "running") continue + if (isStaleQuestionTool(toolPart.tool, toolPart.status, toolPart.startedAt ?? meta.time?.created ?? null, nowMs)) { + continue + } activeTool = toolPart break } + if (activeTool) break } let latestTerminalStatus: "error" | "completed" | null = null diff --git a/src/ingest/sqlite-derive.ts b/src/ingest/sqlite-derive.ts index f693e4a..51bdd0f 100644 --- a/src/ingest/sqlite-derive.ts +++ b/src/ingest/sqlite-derive.ts @@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite" import { BACKGROUND_RUNNING_WINDOW_MS, hasFreshMainSessionActivity, + isStaleQuestionTool, resolveLastUpdatedTime, shouldKeepQueuedBackgroundTaskActive, } from "./activity-status" @@ -160,12 +161,16 @@ function mapToolPartsByMessage(parts: StoredToolPart[]): Map, + nowMs: number, ): string | null { for (const meta of metas) { const parts = partsByMessage.get(meta.id) ?? [] for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] if (isActiveQuestionTool(part.tool, part.state.status)) { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } return part.tool } } @@ -307,6 +312,9 @@ export function getMainSessionViewSqlite(opts: { for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] if (part.state.status === "pending" || part.state.status === "running") { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } activeTool = { tool: part.tool, status: part.state.status } break } @@ -582,7 +590,7 @@ export function deriveBackgroundTasksSqlite(opts: { if (background && !background.ok) return background const backgroundMetas = background?.ok ? background.value.metas : [] const backgroundPartsByMessage = background?.ok ? background.value.partsByMessage : new Map() - const activeQuestionTool = findActiveQuestionTool(backgroundMetas, backgroundPartsByMessage) + const activeQuestionTool = findActiveQuestionTool(backgroundMetas, backgroundPartsByMessage, nowMs) const { toolCalls, lastTool, lastUpdateAt } = computeBackgroundTaskStatsSqlite(backgroundMetas, backgroundPartsByMessage) const lastModel = backgroundMetas.length > 0 ? pickLatestModelString(backgroundMetas) : null From 07745e785d509d5b8b4a7627e4cf9062529abd4a Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 17 May 2026 00:19:39 +0200 Subject: [PATCH 05/23] fix(ingest): suppress stale background questions --- src/__tests__/background-tasks-derive.test.ts | 72 +++++++++++++++++++ src/ingest/background-tasks.ts | 10 ++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/__tests__/background-tasks-derive.test.ts b/src/__tests__/background-tasks-derive.test.ts index 34d380e..f52f9fa 100644 --- a/src/__tests__/background-tasks-derive.test.ts +++ b/src/__tests__/background-tasks-derive.test.ts @@ -605,6 +605,78 @@ describe("deriveBackgroundTasks (file-based)", () => { }); }); + it("does not mark file-backed background tasks as question for stale running question tools", () => { + const storage = makeTempStorage(); + const nowMs = 1_000_000; + + writeJson(path.join(storage.message, "ses-main", "msg-main.json"), { + id: "msg-main", + sessionID: "ses-main", + role: "assistant", + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + agent: "sisyphus", + } satisfies StoredMessageMeta); + + writeJson(path.join(storage.part, "msg-main", "0001.json"), { + id: "part-main", + sessionID: "ses-main", + messageID: "msg-main", + type: "tool", + callID: "call-bg", + tool: "background_task", + state: { + status: "completed", + input: { + description: "Ask stale question", + run_in_background: true, + subagent_type: "atlas", + }, + metadata: { sessionId: "ses-child" }, + time: { start: nowMs - 2_000 }, + }, + } satisfies PersistedToolPart); + + writeJson(path.join(storage.message, "ses-child", "msg-child.json"), { + id: "msg-child", + sessionID: "ses-child", + role: "assistant", + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + agent: "atlas", + } satisfies StoredMessageMeta); + + writeJson(path.join(storage.part, "msg-child", "0001.json"), { + id: "part-child", + sessionID: "ses-child", + messageID: "msg-child", + type: "tool", + callID: "call-question", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: nowMs - 700_000 }, + }, + } satisfies PersistedToolPart); + + writeSessionMeta(storage, "child", { + id: "ses-child", + projectID: "proj-1", + directory: "/tmp/project", + parentID: "ses-main", + title: "Task: Ask stale question", + time: { created: nowMs - 2_000, updated: nowMs - 1_000 }, + }); + + const rows = deriveBackgroundTasks({ + storage, + mainSessionId: "ses-main", + nowMs, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.status).not.toBe("question"); + }); + it("keeps stale unlinked background task as unknown", () => { const storage = makeTempStorage(); const nowMs = 2_000_000; diff --git a/src/ingest/background-tasks.ts b/src/ingest/background-tasks.ts index bd06298..3b453cc 100644 --- a/src/ingest/background-tasks.ts +++ b/src/ingest/background-tasks.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs" import * as path from "node:path" -import { BACKGROUND_RUNNING_WINDOW_MS, shouldKeepQueuedBackgroundTaskActive } from "./activity-status" +import { BACKGROUND_RUNNING_WINDOW_MS, isStaleQuestionTool, shouldKeepQueuedBackgroundTaskActive } from "./activity-status" import { formatTimeline } from "./format-utils" import { pickLatestModelString } from "./model" import { getMessageDir } from "./paths" @@ -170,7 +170,8 @@ export function readAllSessionMetas(sessionStorage: string, fsLike: FsLike = fs) function deriveBackgroundSessionStats( storage: OpenCodeStorageRoots, metas: StoredMessageMeta[], - fsLike: FsLike + fsLike: FsLike, + nowMs: number, ): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null; activeQuestionTool: string | null } { let toolCalls = 0 let lastTool: string | null = null @@ -189,6 +190,9 @@ function deriveBackgroundSessionStats( for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] if (isActiveQuestionTool(part.tool, part.state.status)) { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } activeQuestionTool = part.tool break } @@ -330,7 +334,7 @@ export function deriveBackgroundTasks(opts: { const cached = backgroundStatsCache.get(sessionId) if (cached) return cached const recent = readBackgroundMetas(sessionId) - const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike) + const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike, nowMs) backgroundStatsCache.set(sessionId, stats) return stats } From 91ca88e38b6a519d383284759eefdb2cf0543c28 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 17 May 2026 00:19:39 +0200 Subject: [PATCH 06/23] fix(ingest): scan past stale included questions --- src/__tests__/session-inclusion.test.ts | 78 +++++++++++++++---------- src/ingest/session-inclusion.ts | 27 +++++---- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/src/__tests__/session-inclusion.test.ts b/src/__tests__/session-inclusion.test.ts index 0f17187..2653da0 100644 --- a/src/__tests__/session-inclusion.test.ts +++ b/src/__tests__/session-inclusion.test.ts @@ -19,6 +19,8 @@ type SessionRow = { type ActivePartRow = { tool: string status?: string + started_at?: number | null + time_created?: number | null } type TerminalPartRow = { @@ -411,7 +413,14 @@ describe("findIncludedSessionsSqlite", () => { }, ], activePartsBySession: { - "stale-question": [{ tool: "question", status: "running" }], + "stale-question": [ + { + tool: "question", + status: "running", + started_at: now - 15 * 60_000, + time_created: now - 15 * 60_000, + }, + ], }, }), "/home/user/project", @@ -421,36 +430,43 @@ describe("findIncludedSessionsSqlite", () => { expect(result).toEqual([]) }) - it("orders fresh running canonical question tools ahead of busy sessions", () => { - const now = Date.now() - const result = runFindIncludedSessionsSqlite( - createMockDb({ - sessionRows: [ - { - id: "question-session", - title: "Question", - directory: "/home/user/project", - time_created: now - 20_000, - time_updated: now - 1_000, - }, - { - id: "busy-session", - title: "Busy", - directory: "/home/user/project", - time_created: now - 20_000, - time_updated: now - 500, - }, - ], - activePartsBySession: { - "question-session": [{ tool: "question", status: "running" }], - }, - }), - "/home/user/project", - 60000, - ) - - expect(result.map((session) => session.id)).toEqual(["question-session", "busy-session"]) - }) + it("orders fresh running canonical question tools ahead of busy sessions", () => { + const now = Date.now() + const result = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "question-session", + title: "Question", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 1_000, + }, + { + id: "busy-session", + title: "Busy", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 500, + }, + ], + activePartsBySession: { + "question-session": [ + { + tool: "question", + status: "running", + started_at: now - 1_000, + time_created: now - 1_000, + }, + ], + }, + }), + "/home/user/project", + 60000, + ) + + expect(result.map((session) => session.id)).toEqual(["question-session", "busy-session"]) + }) it("handles mixed sessions: active top-level, stale excluded, child/background excluded", () => { const now = Date.now() diff --git a/src/ingest/session-inclusion.ts b/src/ingest/session-inclusion.ts index a18d2f0..12db0d5 100644 --- a/src/ingest/session-inclusion.ts +++ b/src/ingest/session-inclusion.ts @@ -1,6 +1,6 @@ import type { Database } from "bun:sqlite" import * as path from "node:path" -import { ACTIVE_BUSY_WINDOW_MS, hasFreshMainSessionActivity, shouldSuppressStaleToolActivity } from "./activity-status" +import { ACTIVE_BUSY_WINDOW_MS, hasFreshMainSessionActivity, isStaleQuestionTool, shouldSuppressStaleToolActivity } from "./activity-status" import { realpathSafe } from "./paths" import type { SessionMetadata } from "./session" import { isActiveQuestionTool } from "./tool-names" @@ -30,17 +30,17 @@ function deriveSessionStatusFromMaps( sessionId: string, lastUpdated: number, nowMs: number, - activePartsMap: Map>, + activePartsMap: Map>, assistantMsgsMap: Map>, ): string { const activeParts = activePartsMap.get(sessionId) ?? [] const ageMs = nowMs - lastUpdated const hasFreshActivity = hasFreshMainSessionActivity(lastUpdated, nowMs) - if (activeParts.length > 0) { - const activePart = activeParts[0] - if (!shouldSuppressStaleToolActivity(activePart.tool, activePart.status, hasFreshActivity)) { - return isActiveQuestionTool(activePart.tool, activePart.status) ? "question" : "running_tool" + for (const activePart of activeParts) { + if (!isStaleQuestionTool(activePart.tool, activePart.status, activePart.startedAt, nowMs)) { + if (isActiveQuestionTool(activePart.tool, activePart.status)) return "question" + if (!shouldSuppressStaleToolActivity(activePart.tool, activePart.status, hasFreshActivity)) return "running_tool" } } @@ -113,23 +113,26 @@ export function findIncludedSessionsSqlite( const candidateIds = candidates.map(s => s.id) // Batch query 1: active parts (pending/running) - const activePartsMap = new Map>() + const activePartsMap = new Map>() const placeholders = candidateIds.map(() => "?").join(",") const activeRows = db .query( `SELECT session_id, json_extract(data, '$.tool') as tool, - json_extract(data, '$.state.status') as status + json_extract(data, '$.state.status') as status, + json_extract(data, '$.state.time.start') as started_at, + time_created FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.state.status') IN ('pending', 'running') ORDER BY time_created DESC` ) - .all(...candidateIds) as Array<{ session_id: string; tool: string; status: string }> + .all(...candidateIds) as Array<{ session_id: string; tool: string; status: string; started_at: number | null; time_created: number | null }> for (const row of activeRows) { + const startedAt = row.started_at ?? row.time_created ?? null const sessionActiveParts = activePartsMap.get(row.session_id) if (!sessionActiveParts) { - activePartsMap.set(row.session_id, [{ tool: row.tool, status: row.status }]) - } else if (sessionActiveParts.length < 1) { - sessionActiveParts.push({ tool: row.tool, status: row.status }) + activePartsMap.set(row.session_id, [{ tool: row.tool, status: row.status, startedAt }]) + } else { + sessionActiveParts.push({ tool: row.tool, status: row.status, startedAt }) } } From 32918fe86aa49c508f0d57ce3e1181769d495302 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Thu, 16 Jul 2026 00:17:47 +0200 Subject: [PATCH 07/23] fix(ingest): stop runaway git diff storm (service deployment of fix/runaway-git-diff) Negative-cache failures with exponential backoff, switch to history-only 'git diff ...HEAD --shortstat', and SIGTERM->SIGKILL escalation on timeout. Same source as branch fix/runaway-git-diff (commit f4dda0a); applied here so the running systemd service (WorkingDirectory=this repo) runs the fix durably across reboot and git operations. --- src/ingest/git-status.ts | 81 ++++++++++++++++++++++++++++--------- src/ingest/git-worktrees.ts | 81 +++++++++++++++++++++++++++++-------- 2 files changed, 127 insertions(+), 35 deletions(-) diff --git a/src/ingest/git-status.ts b/src/ingest/git-status.ts index c255ca6..159dbf1 100644 --- a/src/ingest/git-status.ts +++ b/src/ingest/git-status.ts @@ -5,8 +5,13 @@ export const GIT_STATUS_CACHE_TTL_MS = 30_000 const MAX_CACHE_SIZE = 100 +const GIT_STATUS_TIMEOUT_MS = 5_000 +const GIT_SIGKILL_GRACE_MS = 500 +const NEGATIVE_CACHE_BASE_MS = 2_000 +const NEGATIVE_CACHE_MAX_MS = GIT_STATUS_CACHE_TTL_MS const cache = new Map() +const negativeCache = new Map() function evictOldestIfFull(): void { if (cache.size < MAX_CACHE_SIZE) return @@ -22,26 +27,52 @@ function evictOldestIfFull(): void { } export async function getGitUncommittedCount(projectRoot: string): Promise { - try { - const cached = cache.get(projectRoot) - if (cached && Date.now() - cached.fetchedAt < GIT_STATUS_CACHE_TTL_MS) { - return cached.count - } + const cached = cache.get(projectRoot) + if (cached && Date.now() - cached.fetchedAt < GIT_STATUS_CACHE_TTL_MS) { + return cached.count + } + + const negative = negativeCache.get(projectRoot) + if (negative && Date.now() < negative.nextRetryAt) { + return undefined + } + + const result = await runGitStatusPorcelain(projectRoot) + if (result === undefined) { + recordFailure(projectRoot) + return undefined + } + + evictOldestIfFull() + cache.set(projectRoot, { count: result, fetchedAt: Date.now() }) + negativeCache.delete(projectRoot) + return result +} + +function recordFailure(projectRoot: string): void { + const prev = negativeCache.get(projectRoot) + const failureCount = (prev?.failureCount ?? 0) + 1 + negativeCache.set(projectRoot, { + nextRetryAt: Date.now() + backoffDelayMs(failureCount), + failureCount, + }) +} + +function backoffDelayMs(failureCount: number): number { + return Math.min(NEGATIVE_CACHE_BASE_MS * 2 ** (failureCount - 1), NEGATIVE_CACHE_MAX_MS) +} + +async function runGitStatusPorcelain(projectRoot: string): Promise { + let outerTimer: ReturnType | undefined + let timedOut = false + try { const proc = Bun.spawn(["git", "status", "--porcelain"], { cwd: projectRoot, stdout: "pipe", stderr: "pipe", }) - const timeoutMs = 5_000 - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - proc.kill() - resolve(undefined) - }, timeoutMs) - }) - const workPromise = (async (): Promise => { const stdout = await new Response(proc.stdout).text() const exitCode = await proc.exited @@ -52,13 +83,27 @@ export async function getGitUncommittedCount(projectRoot: string): Promise((resolve) => { + outerTimer = setTimeout(() => { + timedOut = true + try { + proc.kill() + } catch {} + setTimeout(() => { + try { + proc.kill("SIGKILL") + } catch {} + }, GIT_SIGKILL_GRACE_MS) + resolve(undefined) + }, GIT_STATUS_TIMEOUT_MS) + }) - evictOldestIfFull() - cache.set(projectRoot, { count: result, fetchedAt: Date.now() }) - return result + return await Promise.race([workPromise, timeoutPromise]) } catch { return undefined + } finally { + if (!timedOut && outerTimer !== undefined) { + clearTimeout(outerTimer) + } } } diff --git a/src/ingest/git-worktrees.ts b/src/ingest/git-worktrees.ts index a7414b7..b819bcb 100644 --- a/src/ingest/git-worktrees.ts +++ b/src/ingest/git-worktrees.ts @@ -4,7 +4,12 @@ export const GIT_WORKTREE_CACHE_TTL_MS = 30_000 const cache = new Map() +const negativeCache = new Map() + const GIT_COMMAND_TIMEOUT_MS = 5_000 +const GIT_SIGKILL_GRACE_MS = 500 +const NEGATIVE_CACHE_BASE_MS = 2_000 +const NEGATIVE_CACHE_MAX_MS = GIT_WORKTREE_CACHE_TTL_MS type ParsedWorktree = { path: string @@ -16,12 +21,43 @@ type ParsedWorktree = { } export async function getWorktreeInfo(projectRoot: string): Promise { - try { - const cached = cache.get(projectRoot) - if (cached && Date.now() - cached.fetchedAt < GIT_WORKTREE_CACHE_TTL_MS) { - return cached.data - } + const cached = cache.get(projectRoot) + if (cached && Date.now() - cached.fetchedAt < GIT_WORKTREE_CACHE_TTL_MS) { + return cached.data + } + + const negative = negativeCache.get(projectRoot) + if (negative && Date.now() < negative.nextRetryAt) { + return undefined + } + + const data = await computeWorktreeInfo(projectRoot) + + if (data === undefined) { + recordFailure(projectRoot) + return undefined + } + + cache.set(projectRoot, { data, fetchedAt: Date.now() }) + negativeCache.delete(projectRoot) + return data +} + +function recordFailure(projectRoot: string): void { + const prev = negativeCache.get(projectRoot) + const failureCount = (prev?.failureCount ?? 0) + 1 + negativeCache.set(projectRoot, { + nextRetryAt: Date.now() + backoffDelayMs(failureCount), + failureCount, + }) +} +function backoffDelayMs(failureCount: number): number { + return Math.min(NEGATIVE_CACHE_BASE_MS * 2 ** (failureCount - 1), NEGATIVE_CACHE_MAX_MS) +} + +async function computeWorktreeInfo(projectRoot: string): Promise { + try { const porcelain = await runGitCommand(projectRoot, ["worktree", "list", "--porcelain"]) if (porcelain === undefined) return undefined @@ -42,7 +78,7 @@ export async function getWorktreeInfo(projectRoot: string): Promise !worktree.isMainWorktree && !worktree.isPrunable).length, hotCount: worktrees.filter(isHotWorktree).length, worktrees, } - - cache.set(projectRoot, { data, fetchedAt: Date.now() }) - return data } catch { return undefined } @@ -92,6 +125,8 @@ async function detectMainBranch(projectRoot: string): Promise { + let outerTimer: ReturnType | undefined + let timedOut = false try { const proc = Bun.spawn(["git", ...args], { cwd, @@ -99,13 +134,6 @@ async function runGitCommand(cwd: string, args: string[]): Promise((resolve) => { - setTimeout(() => { - proc.kill() - resolve(undefined) - }, GIT_COMMAND_TIMEOUT_MS) - }) - const workPromise = (async (): Promise => { const stdout = await new Response(proc.stdout).text() const exitCode = await proc.exited @@ -115,9 +143,28 @@ async function runGitCommand(cwd: string, args: string[]): Promise((resolve) => { + outerTimer = setTimeout(() => { + timedOut = true + try { + proc.kill() + } catch {} + setTimeout(() => { + try { + proc.kill("SIGKILL") + } catch {} + }, GIT_SIGKILL_GRACE_MS) + resolve(undefined) + }, GIT_COMMAND_TIMEOUT_MS) + }) + return await Promise.race([workPromise, timeoutPromise]) } catch { return undefined + } finally { + if (!timedOut && outerTimer !== undefined) { + clearTimeout(outerTimer) + } } } From e28adf8598faf5ae76aa0cbe0b92b930f63eeba6 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Thu, 16 Jul 2026 17:28:59 +0200 Subject: [PATCH 08/23] feat(ui): ambient background activity chart with 3-way mode setting Restyle the per-project activity sparkline as a full-width ambient background layer behind the strip header, fading bottom-up so the status dot, badges, and counts stay crisp. Add an "Activity Chart" setting (Ambient / Inline / Off) replacing the legacy on/off toggle, with migration of the persisted boolean to the new 3-way mode. --- src/__tests__/project-strip-history.test.tsx | 2 +- src/__tests__/project-strip-status.test.tsx | 26 +++- src/styles/tokens.css | 17 +++ src/types.ts | 5 +- src/ui/App.tsx | 5 +- src/ui/components/ProjectStrip.css | 52 +++++++- src/ui/components/ProjectStrip.tsx | 22 +++- src/ui/components/SettingsPanel.tsx | 47 ++++++- src/ui/components/Sparkline.css | 37 ++++++ src/ui/components/Sparkline.tsx | 124 ++++++++++++++++++- src/ui/hooks/useStripConfig.ts | 23 +++- 11 files changed, 342 insertions(+), 18 deletions(-) diff --git a/src/__tests__/project-strip-history.test.tsx b/src/__tests__/project-strip-history.test.tsx index 3828368..df1f10e 100644 --- a/src/__tests__/project-strip-history.test.tsx +++ b/src/__tests__/project-strip-history.test.tsx @@ -35,7 +35,7 @@ describe("ProjectStrip plan history rendering", () => { } const baseConfig: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, diff --git a/src/__tests__/project-strip-status.test.tsx b/src/__tests__/project-strip-status.test.tsx index 9cd8877..a164527 100644 --- a/src/__tests__/project-strip-status.test.tsx +++ b/src/__tests__/project-strip-status.test.tsx @@ -36,7 +36,7 @@ const baseProject: ProjectSnapshot = { } const baseConfig: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, @@ -149,6 +149,30 @@ describe("ProjectStrip rendered status", () => { expect(html).toContain('data-status="idle"') expect(html).toContain('data-stale="true"') }) + + it("does not mark bg_agent strips as stale even when main session is idle", () => { + // bg_agent = project has running background task; main session is idle/stale. + // Panel must show active styling, not the dimmed stale overlay. + const staleTime = new Date(Date.now() - 6 * 60_000).toISOString() + const project = { + ...baseProject, + mainSession: { + ...baseProject.mainSession, + lastUpdated: staleTime, + status: "idle" as const, + }, + aggregateStatus: "bg_agent" as const, + } + + const html = renderToStaticMarkup( + {}} stripConfig={baseConfig}> + {children} + + ) + + expect(html).toContain('data-status="bg_agent"') + expect(html).toContain('data-stale="false"') + }) }) describe("computeDisplayStatus", () => { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 9eed0fa..8caa611 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -138,6 +138,15 @@ --sparkline-fill-sand: rgba(217, 70, 239, 0.8); --sparkline-fill-muted: rgba(148, 163, 184, 0.5); --sparkline-drop-shadow-muted: rgba(148, 163, 184, 0.4); + + /* Background activity bars (ambient sparkline behind header) */ + --bg-activity-teal: rgba(13, 148, 136, 0.32); + --bg-activity-red: rgba(225, 29, 72, 0.32); + --bg-activity-green: rgba(22, 163, 74, 0.32); + --bg-activity-sand: rgba(217, 119, 6, 0.32); + --bg-activity-muted: rgba(255, 255, 255, 0.09); + --bg-activity-flatline: rgba(255, 255, 255, 0.55); + --bg-activity-blend: normal; --swimlane-dot-teal: rgba(0, 212, 170, 0.7); --swimlane-dot-red: rgba(255, 107, 107, 0.7); --swimlane-dot-green: rgba(78, 205, 196, 0.7); @@ -246,6 +255,14 @@ --sparkline-fill-sand: rgba(180, 60, 200, 0.8); --sparkline-fill-muted: rgba(148, 163, 184, 0.4); --sparkline-drop-shadow-muted: rgba(148, 163, 184, 0.3); + + --bg-activity-teal: rgba(15, 118, 110, 0.30); + --bg-activity-red: rgba(190, 18, 60, 0.30); + --bg-activity-green: rgba(21, 128, 61, 0.30); + --bg-activity-sand: rgba(180, 83, 9, 0.30); + --bg-activity-muted: rgba(0, 0, 0, 0.08); + --bg-activity-flatline: rgba(0, 0, 0, 0.48); + --bg-activity-blend: normal; --swimlane-dot-teal: rgba(0, 155, 122, 0.7); --swimlane-dot-red: rgba(212, 64, 64, 0.7); --swimlane-dot-green: rgba(42, 157, 143, 0.7); diff --git a/src/types.ts b/src/types.ts index 297d89a..f3f7a8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,8 +206,11 @@ export type DashboardMultiProjectPayload = { } /** Configuration state for strip visibility options */ +/** Sparkline rendering mode in the collapsed strip header */ +export type MiniSparklineMode = "ambient" | "inline" | "off" + export type StripConfigState = { - showMiniSparkline: boolean + miniSparklineMode: MiniSparklineMode showPlanProgress: boolean showAgentBadge: boolean showLastUpdated: boolean diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 09431cf..1a524f2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -156,7 +156,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { config: soundConfig, setConfig: setSoundConfig, playWaiting, playAllClear, playAttention, playQuestion } = useSoundNotifications() const { orderedIds, columns, reorder, setColumns, syncIds } = useProjectOrder() const { visibility, isVisible, toggleVisibility } = useProjectVisibility() - const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode } = useStripConfig() + const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode, setMiniSparklineMode } = useStripConfig() const [activeOverlay, setActiveOverlay] = useState('none') /* ── Zoom ── */ @@ -505,6 +505,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap stripConfig={stripConfig} onToggleStrip={toggleStripConfig} onSetStripMode={setStripMode} + onSetMiniSparklineMode={setMiniSparklineMode} soundConfig={soundConfig} onSoundConfigChange={setSoundConfig} onTestSound={handleTestSound} @@ -589,7 +590,7 @@ function ProjectStripWithChildren({ project, expanded, onToggleExpand, stripConf {{ miniSparkline: ( ), diff --git a/src/ui/components/ProjectStrip.css b/src/ui/components/ProjectStrip.css index 5d21bc5..f895261 100644 --- a/src/ui/components/ProjectStrip.css +++ b/src/ui/components/ProjectStrip.css @@ -180,6 +180,8 @@ align-items: center; gap: var(--sp-3); height: var(--collapsed-pane-height, 40px); + position: relative; + z-index: 3; padding: 0 var(--sp-3); cursor: pointer; user-select: none; @@ -188,6 +190,8 @@ .strip-header-row { display: flex; + position: relative; + z-index: 3; align-items: center; gap: var(--sp-2); } @@ -204,7 +208,7 @@ } .strip-header:hover { - background: var(--bg-tertiary); + background: color-mix(in srgb, var(--bg-tertiary) 78%, transparent); } .strip-header:focus-visible { outline: 2px solid var(--accent-primary); @@ -476,6 +480,35 @@ border: 1px solid var(--bg-tertiary); } +/* ── Background activity layer (ambient bars behind header) ── */ +.strip-bg-sparkline { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--collapsed-pane-height, 40px); + z-index: 0; + pointer-events: none; + mix-blend-mode: var(--bg-activity-blend, screen); + -webkit-mask-image: linear-gradient(to top, #000 0%, transparent 85%); + mask-image: linear-gradient(to top, #000 0%, transparent 85%); +} + +[data-density="ultra-dense"] .strip-bg-sparkline { + height: var(--collapsed-pane-height, 36px); +} + +/* Dim ambient bars on inactive / unknown states */ +.project-strip[data-status="idle"] .strip-bg-sparkline, +.project-strip[data-status="error"] .strip-bg-sparkline, +.project-strip[data-status="unknown"] .strip-bg-sparkline { + opacity: 0.3; +} + +.project-strip[data-stale="true"] .strip-bg-sparkline { + opacity: 0.2; +} + /* Agent badge */ .strip-agent-badge { display: inline-flex; @@ -546,6 +579,8 @@ /* ── Expanded body ── */ .strip-body { max-height: 0; + position: relative; + z-index: 3; overflow: hidden; transition: max-height var(--transition-normal); } @@ -621,6 +656,11 @@ min-width: 0; } +.strip-top-row-activity > .strip-section { + width: 100%; + min-width: 0; +} + .strip-section--token-usage { flex: 0 0 136px; min-width: 120px; @@ -767,6 +807,7 @@ flex-direction: column; gap: var(--sp-2); align-items: flex-start; + width: 100%; font-family: var(--font-mono); font-size: var(--font-xs); } @@ -1095,11 +1136,18 @@ @media (max-width: 900px) { .strip-top-row { flex-direction: column; + flex-wrap: nowrap; } - .strip-section--token-usage { + .strip-top-row-activity { flex: 1 1 auto; + width: 100%; + } + + .strip-section--token-usage { + flex: 0 0 auto; min-width: 0; + width: 100%; } } diff --git a/src/ui/components/ProjectStrip.tsx b/src/ui/components/ProjectStrip.tsx index b902eb1..7e3baa1 100644 --- a/src/ui/components/ProjectStrip.tsx +++ b/src/ui/components/ProjectStrip.tsx @@ -144,6 +144,10 @@ type StripHeaderContentProps = { const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisplayStatus, isStale, stripConfig, mainSession, gitUncommittedCount, unintiatedPlans, children, slots }: StripHeaderContentProps) { const slotContent = slots ?? children + const uninitiatedPlanTooltip = unintiatedPlans && unintiatedPlans.length > 0 + ? `${unintiatedPlans.length} uninitiated plan${unintiatedPlans.length === 1 ? "" : "s"}:\n${unintiatedPlans.map((plan) => `- ${plan.name}`).join("\n")}` + : undefined + return ( <> {stripConfig?.showStatusDot !== false && ( @@ -175,7 +179,7 @@ const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisp )} )} - {stripConfig?.showMiniSparkline !== false &&
{slotContent?.miniSparkline}
} + {stripConfig?.miniSparklineMode === "inline" &&
{slotContent?.miniSparkline}
} {stripConfig?.showAgentBadge !== false && {mainSession.agent}} {gitUncommittedCount != null && gitUncommittedCount > 0 && ( @@ -196,7 +200,12 @@ const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisp )} {stripConfig?.showPlanProgress !== false &&
{slotContent?.compactPlan}
} {unintiatedPlans && unintiatedPlans.length > 0 && ( - {unintiatedPlans.length} + + {unintiatedPlans.length} + )} {stripConfig?.showLastUpdated !== false && {mainSession.lastUpdated ? formatRelativeTime(new Date(mainSession.lastUpdated).getTime()) : "—"}} @@ -445,7 +454,9 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl : displayStatus const isStale = (() => { - const activeStates = ['busy', 'thinking', 'running_tool', 'running_script', 'question', 'error'] + // bg_agent: project with running background task — main session may be idle/stale, + // but the project IS active (aggregateStatus promoted in multi-project.ts). + const activeStates = ['busy', 'thinking', 'running_tool', 'running_script', 'bg_agent', 'question', 'error'] if (activeStates.includes(finalDisplayStatus)) return false if (planProgress?.planStale) return true if (!mainSession?.lastUpdated) return true @@ -542,6 +553,11 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl return (
+ {stripConfig?.miniSparklineMode === "ambient" && children?.miniSparkline && ( + + )} {previewPublicName ? ( void onSetStripMode: (mode: "project" | "session") => void + onSetMiniSparklineMode: (mode: MiniSparklineMode) => void soundConfig: SoundConfig onSoundConfigChange: (config: SoundConfig) => void onTestSound: (event: "idle" | "complete" | "error" | "question") => void @@ -55,10 +56,9 @@ export type SettingsPanelProps = { /* ── Display toggle metadata ── */ -const COLLAPSED_TOGGLES: { key: Extract; label: string }[] = [ +const COLLAPSED_TOGGLES: { key: Extract; label: string }[] = [ { key: "showProjectName", label: "Project Name" }, { key: "showStatusDot", label: "Status Dot" }, - { key: "showMiniSparkline", label: "Mini Sparkline" }, { key: "showPlanProgress", label: "Plan Progress" }, { key: "showAgentBadge", label: "Agent Badge" }, { key: "showLastUpdated", label: "Last Updated" }, @@ -90,6 +90,7 @@ export function SettingsPanel({ stripConfig, onToggleStrip, onSetStripMode, + onSetMiniSparklineMode, soundConfig, onSoundConfigChange, onTestSound, @@ -165,6 +166,10 @@ export function SettingsPanel({ const handleSetStripModeProject = useCallback(() => onSetStripMode("project"), [onSetStripMode]) const handleSetStripModeSession = useCallback(() => onSetStripMode("session"), [onSetStripMode]) + + const handleSetMiniSparklineModeAmbient = () => onSetMiniSparklineMode("ambient") + const handleSetMiniSparklineModeInline = () => onSetMiniSparklineMode("inline") + const handleSetMiniSparklineModeOff = () => onSetMiniSparklineMode("off") const handleCollapsedHeightChange = useCallback( (e: React.ChangeEvent) => onCollapsedHeightChange(Number(e.target.value)), [onCollapsedHeightChange], @@ -216,6 +221,42 @@ export function SettingsPanel({
+
+ Activity Chart +
+ + + +
+
+

Collapsed View

{COLLAPSED_TOGGLES.map(({ key, label }) => (
diff --git a/src/ui/components/Sparkline.css b/src/ui/components/Sparkline.css index b53392d..35cca53 100644 --- a/src/ui/components/Sparkline.css +++ b/src/ui/components/Sparkline.css @@ -82,3 +82,40 @@ fill: var(--sparkline-fill-muted); filter: drop-shadow(0 0 4px var(--sparkline-drop-shadow-muted)); } + +/* ── Background (ambient) mode — full-panel behind header content ── */ + +.sparkline--bg { + width: 100%; + height: 100%; + display: block; +} + +.sparkline-bg-bar { + shape-rendering: crispEdges; +} + +.sparkline-bg-bar--teal { + fill: var(--bg-activity-teal); +} + +.sparkline-bg-bar--red { + fill: var(--bg-activity-red); +} + +.sparkline-bg-bar--green { + fill: var(--bg-activity-green); +} + +.sparkline-bg-bar--sand { + fill: var(--bg-activity-sand); +} + +.sparkline-bg-bar--muted { + fill: var(--bg-activity-muted); +} + +.sparkline-bg-flatline { + fill: var(--bg-activity-flatline); + shape-rendering: crispEdges; +} diff --git a/src/ui/components/Sparkline.tsx b/src/ui/components/Sparkline.tsx index 6db1b4e..0def0a2 100644 --- a/src/ui/components/Sparkline.tsx +++ b/src/ui/components/Sparkline.tsx @@ -23,7 +23,7 @@ interface AgentCounts { } export interface SparklineProps { - mode: "mini" | "full" + mode: "mini" | "full" | "bg" timeSeries: TimeSeriesPayload width?: number height?: number @@ -207,6 +207,9 @@ function SparklineInner({ if (mode === "mini") { return renderMini(buckets, lookup, barW, barInset, width, height, className) } + if (mode === "bg") { + return renderBg(buckets, lookup, barW, barInset, width, height, className) + } return renderFull(buckets, lookup, barW, barInset, width, height, className) } @@ -481,3 +484,122 @@ function renderFull( ) } + +/* ── Background mode (ambient layer behind header) ── */ + +function renderBg( + totalBuckets: number, + lookup: Map, + barW: number, + barInset: number, + widthProp: number | undefined, + heightProp: number | undefined, + className: string | undefined, +) { + const h = heightProp ?? 40 + const windowSize = Math.min(totalBuckets, 300) + const chartH = h + + const sisV = lookup.get("agent:sisyphus")?.values ?? [] + const proV = lookup.get("agent:prometheus")?.values ?? [] + const atlV = lookup.get("agent:atlas")?.values ?? [] + const bgV = lookup.get("background-total")?.values ?? [] + + const startIdx = 0 + const count = windowSize + + /* Sum main agents per bucket + track dominant tone */ + const sums: number[] = [] + const dominantTones: AgentTone[] = [] + let maxVal = 0 + let hasAny = false + for (let i = 0; i < count; i++) { + const idx = startIdx + i + const sisVal = toSafe(sisV[idx]) + const proVal = toSafe(proV[idx]) + const atlVal = toSafe(atlV[idx]) + const sum = sisVal + proVal + atlVal + sums.push(sum) + if (sum > 0) hasAny = true + if (sum > maxVal) maxVal = sum + + /* Determine dominant agent tone for this bucket */ + let dominantTone: AgentTone = "teal" /* default */ + const maxValBucket = Math.max(sisVal, proVal, atlVal) + if (maxValBucket > 0) { + if (sisVal === maxValBucket) { + dominantTone = "teal" + } else if (proVal === maxValBucket) { + dominantTone = "red" + } else if (atlVal === maxValBucket) { + dominantTone = "green" + } + } + dominantTones.push(dominantTone) + } + + /* Account for background-agent bars in scale */ + for (let i = 0; i < count; i++) { + const idx = startIdx + i + const bgVal = toSafe(bgV[idx]) + if (bgVal > 0) hasAny = true + const candidate = Math.max(sums[i], bgVal) + if (candidate > maxVal) maxVal = candidate + } + const scaleMax = Math.max(1, maxVal) + + const viewBox = `0 0 ${count} ${h}` + const cls = ["sparkline", "sparkline--bg", className].filter(Boolean).join(" ") + + return ( + + ) +} diff --git a/src/ui/hooks/useStripConfig.ts b/src/ui/hooks/useStripConfig.ts index 3b716c8..a26b2eb 100644 --- a/src/ui/hooks/useStripConfig.ts +++ b/src/ui/hooks/useStripConfig.ts @@ -1,11 +1,11 @@ import { useState, useCallback, useEffect } from "react" -import type { StripConfigState } from "../../types" +import type { StripConfigState, MiniSparklineMode } from "../../types" const STORAGE_KEY = "dashboard-strip-config" /** Default strip configuration with all toggles enabled */ const DEFAULT_CONFIG: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, @@ -25,7 +25,14 @@ function readPersistedConfig(): StripConfigState { if (!raw) return DEFAULT_CONFIG const parsed: unknown = JSON.parse(raw) if (typeof parsed !== "object" || parsed === null) return DEFAULT_CONFIG - return { ...DEFAULT_CONFIG, ...parsed } + const merged = { ...DEFAULT_CONFIG, ...(parsed as object) } as StripConfigState & { showMiniSparkline?: unknown } + /* Migrate legacy boolean toggle → 3-way mode */ + const parsedObj = parsed as Record + if (!("miniSparklineMode" in parsedObj) && "showMiniSparkline" in parsedObj) { + merged.miniSparklineMode = parsedObj.showMiniSparkline === false ? "off" : "ambient" + } + delete (merged as { showMiniSparkline?: unknown }).showMiniSparkline + return merged } catch { return DEFAULT_CONFIG } @@ -48,6 +55,7 @@ export function useStripConfig(): { config: StripConfigState toggle: (key: keyof StripConfigState) => void setMode: (mode: "project" | "session") => void + setMiniSparklineMode: (mode: MiniSparklineMode) => void reset: () => void } { const [config, setConfig] = useState(() => readPersistedConfig()) @@ -71,9 +79,16 @@ export function useStripConfig(): { })) }, []) + const setMiniSparklineMode = useCallback((mode: MiniSparklineMode) => { + setConfig((prev) => ({ + ...prev, + miniSparklineMode: mode, + })) + }, []) + const reset = useCallback(() => { setConfig(DEFAULT_CONFIG) }, []) - return { config, toggle, setMode, reset } + return { config, toggle, setMode, setMiniSparklineMode, reset } } From 3ac79670fe1f8973776cf868caad82531564d420 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 11:45:07 +0200 Subject: [PATCH 09/23] feat(ui): provider quota strip with live usage from 5 providers --- AGENTS.md | 1 + src/__tests__/quotas.test.ts | 317 +++++++++++++++ src/server/AGENTS.md | 1 + src/server/api.ts | 14 +- src/server/quotas.ts | 645 +++++++++++++++++++++++++++++++ src/types.ts | 32 ++ src/ui/App.tsx | 4 + src/ui/components/QuotaStrip.css | 92 +++++ src/ui/components/QuotaStrip.tsx | 75 ++++ src/ui/hooks/useQuotas.ts | 58 +++ 10 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/quotas.test.ts create mode 100644 src/server/quotas.ts create mode 100644 src/ui/components/QuotaStrip.css create mode 100644 src/ui/components/QuotaStrip.tsx create mode 100644 src/ui/hooks/useQuotas.ts diff --git a/AGENTS.md b/AGENTS.md index d4e4fa7..6c3e2ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,7 @@ All routes are under `/api` prefix (via Vite proxy in dev, direct in production) - `POST /api/sources` — Register new project source - `GET /api/projects` — All projects with full snapshots (main polling endpoint) - `GET /api/projects/:sourceId` — Single project detail +- `GET /api/quotas` — Provider subscription quota usage (5 providers, server-cached ~3 min) - `GET /api/tool-calls/:sessionId` — Tool call history for a session - `GET /api/service/status` — Systemd service status - `POST /api/service/enable` — Enable systemd auto-start diff --git a/src/__tests__/quotas.test.ts b/src/__tests__/quotas.test.ts new file mode 100644 index 0000000..19aa6ad --- /dev/null +++ b/src/__tests__/quotas.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from "vitest" +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createQuotaService, + parseAuthFile, + parseGoUsage, + parseKimiUsage, + parseOllamaUsage, + parseOpenAiUsage, + parseZaiUsage, + type FetchLike, +} from "../server/quotas" +import type { ProviderQuotasPayload } from "../types" + +/* ── Test helpers ── */ + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +const NOW_MS = Date.UTC(2026, 8, 12, 12, 0, 0) + +/* ── Parsers ── */ + +describe("parseGoUsage", () => { + it("maps rolling/weekly/monthly windows", () => { + const windows = parseGoUsage({ + usage: { + rolling: { status: "ok", percent: 12.4, resetsAt: "2026-09-12T16:00:00Z" }, + weekly: { status: "ok", percent: 40, resetsAt: "2026-09-14T00:00:00Z" }, + monthly: { status: "rate-limited", percent: 99.9, resetsAt: null }, + }, + }) + expect(windows).toHaveLength(3) + expect(windows[0]).toMatchObject({ id: "5h", shortLabel: "5H", usedPercent: 12.4 }) + expect(windows[0].resetsAtMs).toBe(Date.parse("2026-09-12T16:00:00Z")) + expect(windows[1]).toMatchObject({ id: "weekly", shortLabel: "WK" }) + expect(windows[2]).toMatchObject({ id: "monthly", shortLabel: "MO", usedPercent: 99.9 }) + expect(windows[2].resetsAtMs).toBeNull() + }) + + it("returns empty for malformed bodies", () => { + expect(parseGoUsage(null)).toEqual([]) + expect(parseGoUsage({})).toEqual([]) + expect(parseGoUsage({ usage: { rolling: { percent: "NaN" } } })).toEqual([]) + }) +}) + +describe("parseZaiUsage", () => { + it("decodes limit rows by unit/number", () => { + const windows = parseZaiUsage({ + code: 200, + success: true, + data: { + level: "max", + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 15, nextResetTime: NOW_MS + 3_600_000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 20, nextResetTime: NOW_MS + 86_400_000 }, + { type: "TIME_LIMIT", unit: 5, number: 1, percentage: 45 }, + { type: "TOKENS_LIMIT", unit: 9, number: 9, percentage: 50 }, // unknown → skipped + ], + }, + }) + expect(windows.map((w) => w.id)).toEqual(["5h", "weekly", "monthly"]) + expect(windows[0]).toMatchObject({ shortLabel: "5H", usedPercent: 15, resetsAtMs: NOW_MS + 3_600_000 }) + expect(windows[2]).toMatchObject({ shortLabel: "MO", resetsAtMs: null }) + }) + + it("derives percentage from currentValue/usage when percentage is missing", () => { + const windows = parseZaiUsage({ + data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, usage: 800, currentValue: 200 }] }, + }) + expect(windows).toHaveLength(1) + expect(windows[0].usedPercent).toBeCloseTo(25) + }) + + it("falls back to legacy flat fields", () => { + const windows = parseZaiUsage({ data: { fiveHourPercent: 10, weeklyPercent: 30 } }) + expect(windows.map((w) => w.id)).toEqual(["5h", "weekly"]) + expect(windows[0].usedPercent).toBe(10) + }) +}) + +describe("parseKimiUsage", () => { + const nowMs = Date.UTC(2026, 8, 12, 12, 0, 0) + + it("coerces string numbers and maps weekly/5h/monthly", () => { + const windows = parseKimiUsage( + { + usage: { limit: "100", remaining: "70", resetTime: "2026-09-14T09:59:07Z" }, + limits: [ + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "40", remaining: "10", resetTime: "2026-09-12T15:00:00Z" }, + }, + ], + totalQuota: { limit: "1000", used: "250" }, + }, + nowMs, + ) + expect(windows).toHaveLength(3) + expect(windows.find((w) => w.id === "weekly")?.usedPercent).toBeCloseTo(30) + expect(windows.find((w) => w.id === "5h")?.usedPercent).toBeCloseTo(75) + const monthly = windows.find((w) => w.id === "monthly") + expect(monthly?.usedPercent).toBeCloseTo(25) + expect(monthly?.resetsAtMs).toBe(Date.UTC(2026, 9, 1, 0, 0, 0)) // next month boundary + }) + + it("derives usedPercent from used when remaining is missing", () => { + const windows = parseKimiUsage({ usage: { limit: "100", used: "25", resetTime: "" } }, nowMs) + expect(windows).toHaveLength(1) + expect(windows[0].usedPercent).toBeCloseTo(25) + }) + + it("returns empty for malformed bodies", () => { + expect(parseKimiUsage(null, nowMs)).toEqual([]) + expect(parseKimiUsage("nope", nowMs)).toEqual([]) + }) +}) + +describe("parseOpenAiUsage", () => { + it("maps primary/secondary windows with epoch-second resets", () => { + const windows = parseOpenAiUsage({ + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 12, + limit_window_seconds: 18000, + reset_after_seconds: 3600, + reset_at: 1_789_000_000, + }, + secondary_window: { used_percent: 4, limit_window_seconds: 604800, reset_after_seconds: 86400 }, + }, + }) + expect(windows).toHaveLength(2) + expect(windows[0]).toMatchObject({ id: "5h", shortLabel: "5H", usedPercent: 12 }) + expect(windows[0].resetsAtMs).toBe(1_789_000_000 * 1000) + expect(windows[1]).toMatchObject({ id: "weekly", shortLabel: "WK", usedPercent: 4 }) + expect(windows[1].resetsAtMs).toBeGreaterThan(Date.now()) + }) + + it("tolerates missing windows", () => { + expect(parseOpenAiUsage({ rate_limit: { primary_window: null } })).toEqual([]) + expect(parseOpenAiUsage({})).toEqual([]) + }) +}) + +describe("parseOllamaUsage", () => { + const nowMs = Date.UTC(2026, 8, 12, 12, 0, 0) + + it("converts 0..1 fractions to percent with epoch-aligned resets", () => { + const windows = parseOllamaUsage( + { limits: { session: { usage: 0.046 }, weekly: { usage: 0.051 } } }, + nowMs, + ) + expect(windows).toHaveLength(2) + expect(windows[0]).toMatchObject({ id: "5h", usedPercent: 4.6 }) + const fiveHourPeriodMs = 5 * 3_600_000 + expect(windows[0].resetsAtMs).toBe((Math.floor(nowMs / fiveHourPeriodMs) + 1) * fiveHourPeriodMs) + expect(windows[1]).toMatchObject({ id: "weekly", usedPercent: 5.1 }) + }) + + it("returns empty when limits absent", () => { + expect(parseOllamaUsage({ limits: {} }, nowMs)).toEqual([]) + expect(parseOllamaUsage(null, nowMs)).toEqual([]) + }) +}) + +describe("parseAuthFile", () => { + it("parses api and oauth entries", () => { + const auth = parseAuthFile( + JSON.stringify({ + "opencode-go": { type: "api", key: "sk-go" }, + openai: { + type: "oauth", + access: "at", + refresh: "rt", + expires: NOW_MS, + accountId: "acc-1", + }, + broken: { type: "api" }, + }), + ) + expect(auth["opencode-go"]).toEqual({ type: "api", key: "sk-go" }) + expect(auth["openai"]).toMatchObject({ type: "oauth", access: "at", accountId: "acc-1" }) + expect(auth["broken"]).toBeUndefined() + }) + + it("returns empty for garbage", () => { + expect(parseAuthFile("not json")).toEqual({}) + expect(parseAuthFile("[1,2]")).toEqual({}) + }) + + it("normalizes second-based expires to ms", () => { + const auth = parseAuthFile( + JSON.stringify({ openai: { type: "oauth", access: "a", refresh: "r", expires: 1_789_000_000 } }), + ) + expect(auth["openai"]?.type === "oauth" && auth["openai"].expiresMs).toBe(1_789_000_000_000) + }) +}) + +/* ── Service ── */ + +async function withTempAuth(contents: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "omo-quotas-")) + const authPath = join(dir, "auth.json") + await writeFile(authPath, contents, "utf8") + return authPath +} + +describe("createQuotaService", () => { + it("reports unconfigured providers when auth entries are missing", async () => { + const authPath = await withTempAuth(JSON.stringify({})) + const service = createQuotaService({ authPath, fetchImpl: async () => jsonResponse({}) }) + const payload = await service.getQuotas() + expect(payload.providers).toHaveLength(5) + expect(payload.providers.every((p) => p.status === "unconfigured")).toBe(true) + }) + + it("isolates per-provider errors and serves from cache within TTL", async () => { + const authPath = await withTempAuth( + JSON.stringify({ + "opencode-go": { type: "api", key: "sk-go" }, + "zai-coding-plan": { type: "api", key: "sk-zai" }, + }), + ) + let callCount = 0 + const fetchImpl: FetchLike = async (input) => { + callCount += 1 + const url = String(input) + if (url.includes("api.opencode.ai")) return jsonResponse({ message: "denied" }, 403) + if (url.includes("api.z.ai")) { + return jsonResponse({ + data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 22 }] }, + }) + } + return jsonResponse({}) + } + let nowMs = NOW_MS + const service = createQuotaService({ + authPath, + fetchImpl, + now: () => nowMs, + cacheTtlMs: 60_000, + }) + + const first = await service.getQuotas() + const go = first.providers.find((p) => p.providerId === "opencode-go") + const zai = first.providers.find((p) => p.providerId === "zai-coding-plan") + expect(go).toMatchObject({ status: "error" }) + expect(go?.error).toContain("403") + expect(zai).toMatchObject({ status: "ok" }) + expect(zai?.windows[0]).toMatchObject({ id: "5h", usedPercent: 22 }) + const callsAfterFirst = callCount + + nowMs += 30_000 // still within TTL + const second = await service.getQuotas() + expect(callCount).toBe(callsAfterFirst) + expect(second.serverNowMs).toBe(NOW_MS) // cached payload keeps first-fetch timestamp + + nowMs += 31_000 // TTL elapsed → refetch + await service.getQuotas() + expect(callCount).toBeGreaterThan(callsAfterFirst) + }) + + it("refreshes expired OpenAI OAuth tokens in memory and uses the new token", async () => { + const authPath = await withTempAuth( + JSON.stringify({ + openai: { type: "oauth", access: "stale", refresh: "rt", expires: Date.now() - 60_000, accountId: "acc" }, + }), + ) + const seenAuth: string[] = [] + const fetchImpl: FetchLike = async (input, init) => { + const url = String(input) + const headers = (init?.headers ?? {}) as Record + if (url.includes("auth.openai.com")) { + expect(init?.method).toBe("POST") + return jsonResponse({ access_token: "fresh", refresh_token: "rt2", expires_in: 3600 }) + } + if (url.includes("wham/usage")) { + seenAuth.push(headers.Authorization ?? "") + seenAuth.push(headers["ChatGPT-Account-Id"] ?? "") + return jsonResponse({ + rate_limit: { primary_window: { used_percent: 33, limit_window_seconds: 18000 } }, + }) + } + return jsonResponse({}) + } + const service = createQuotaService({ authPath, fetchImpl }) + const payload = await service.getQuotas() + const openai = payload.providers.find((p) => p.providerId === "openai") + expect(openai).toMatchObject({ status: "ok" }) + expect(openai?.windows[0]).toMatchObject({ id: "5h", usedPercent: 33 }) + expect(seenAuth[0]).toBe("Bearer fresh") + expect(seenAuth[1]).toBe("acc") + }) + + it("reports an error payload shape matching ProviderQuotasPayload", async () => { + const authPath = await withTempAuth( + JSON.stringify({ "ollama-cloud": { type: "api", key: "sk-ol" } }), + ) + const fetchImpl: FetchLike = async () => jsonResponse({ limits: { session: { usage: 0.5 } } }) + const service = createQuotaService({ authPath, fetchImpl }) + const payload: ProviderQuotasPayload = await service.getQuotas() + expect(payload.serverNowMs).toBeGreaterThan(0) + const ollama = payload.providers.find((p) => p.providerId === "ollama-cloud") + expect(ollama?.windows.map((w) => w.id)).toContain("5h") + expect(typeof ollama?.symbol).toBe("string") + }) +}) diff --git a/src/server/AGENTS.md b/src/server/AGENTS.md index edf633d..659d64c 100644 --- a/src/server/AGENTS.md +++ b/src/server/AGENTS.md @@ -13,6 +13,7 @@ Hono HTTP server + multi-project dashboard assembly. | `api.ts` | MED | `createApi()` factory — all REST routes, middleware, error handler | | `dashboard.ts` | HIGH | `buildDashboardPayload()` (SQLite) + `buildDashboardPayloadFiles()` (files) + `createDashboardStore()` | | `multi-project.ts` | MED | `createMultiProjectService()` — one `DashboardStore` per registered source | +| `quotas.ts` | MED | `createQuotaService()` — provider quota fetchers (auth.json read-only, TTL cache) | | `dev.ts` | LOW | Dev entry: Bun.serve on `OMO_PULSE_API_PORT` (4301) | | `start.ts` | LOW | Prod entry: serves Vite-built SPA + API on `OMO_PULSE_PORT` (4300) | diff --git a/src/server/api.ts b/src/server/api.ts index ba0dc97..b4e128a 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -8,6 +8,7 @@ import { assertAllowedPath, expandTilde } from "../ingest/paths" import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "../ingest/tool-calls" import { deriveToolCallsSqlite } from "../ingest/sqlite-derive" import type { StorageBackend } from "../ingest/storage-backend" +import { createQuotaService } from "./quotas" import type { DashboardMultiProjectPayload, TelegramServiceStatus } from "../types" const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ @@ -27,6 +28,7 @@ export function createApi(opts: { const api = new Hono() const version = opts.version ?? "0.0.0" const multiProjectService = opts.multiProjectService + const quotaService = createQuotaService() const invalidateProjects = (): void => { multiProjectService.invalidate() } @@ -134,9 +136,17 @@ export function createApi(opts: { return c.json(project) }) - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // GET /quotas — provider subscription quota usage (cached ~3 min server-side) + // ------------------------------------------------------------------------- + api.get("/quotas", async (c) => { + const payload = await quotaService.getQuotas() + return c.json({ ok: true, ...payload }) + }) + + // ------------------------------------------------------------------------- // GET /tool-calls/:sessionId — tool call details per session - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- api.get("/tool-calls/:sessionId", (c) => { const sessionId = c.req.param("sessionId") if (!SESSION_ID_PATTERN.test(sessionId)) { diff --git a/src/server/quotas.ts b/src/server/quotas.ts new file mode 100644 index 0000000..2e3c4e4 --- /dev/null +++ b/src/server/quotas.ts @@ -0,0 +1,645 @@ +/** + * Provider quota service — queries subscription usage limits for providers + * configured in OpenCode's auth.json. + * + * Read-only observer: auth.json is never written. OAuth token refreshes are + * held in memory only (OpenCode rotates the persisted tokens itself). + * + * Endpoints (community/codex-sourced, undocumented where noted): + * - OpenCode Go: GET https://opencode.ai/zen/go/v1/usage + * - Z.AI: GET https://api.z.ai/api/monitor/usage/quota/limit (undocumented) + * - Kimi: GET https://api.kimi.com/coding/v1/usages (undocumented) + * - ChatGPT: GET https://chatgpt.com/backend-api/wham/usage (codex-rs contract) + * - Ollama Cloud: GET https://ollama.com/api/usage (undocumented) + */ + +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { getDataDir } from "../ingest/paths" +import type { + ProviderQuota, + ProviderQuotasPayload, + QuotaWindow, +} from "../types" + +/* ── Constants ── */ + +const CACHE_TTL_MS = 3 * 60_000 +const FETCH_TIMEOUT_MS = 10_000 + +const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token" +const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token" +const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" + +const GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage" +const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit" +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages" +const OPENAI_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" +const OLLAMA_USAGE_URL = "https://ollama.com/api/usage" + +const HOUR_SECONDS = 3_600 +const FIVE_HOURS_SECONDS = 5 * HOUR_SECONDS +const WEEK_SECONDS = 7 * 24 * HOUR_SECONDS + +/** Minimal structural fetch signature — injectable for tests. */ +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise + +const GO_MONTH_SECONDS = 30 * 24 * HOUR_SECONDS + +/* ── auth.json shape (read-only) ── */ + +type AuthEntryApi = { type: "api"; key: string } +type AuthEntryOauth = { + type: "oauth" + access: string + refresh: string + expiresMs: number + accountId?: string +} +type AuthEntry = AuthEntryApi | AuthEntryOauth +type AuthFile = Record + +function normalizeExpiresMs(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0 + // OpenCode stores ms epoch; tolerate seconds. + return value < 1e12 ? value * 1_000 : value +} + +export function parseAuthFile(raw: string): AuthFile { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return {} + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {} + const out: AuthFile = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "object" || value === null) continue + const entry = value as Record + if (entry.type === "api" && typeof entry.key === "string" && entry.key.length > 0) { + out[key] = { type: "api", key: entry.key } + } else if ( + entry.type === "oauth" && + typeof entry.access === "string" && entry.access.length > 0 && + typeof entry.refresh === "string" && entry.refresh.length > 0 + ) { + out[key] = { + type: "oauth", + access: entry.access, + refresh: entry.refresh, + expiresMs: normalizeExpiresMs(entry.expires), + accountId: typeof entry.accountId === "string" ? entry.accountId : undefined, + } + } + } + return out +} + +/* ── Helpers ── */ + +function toNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim() !== "") { + const n = Number(value) + if (Number.isFinite(n)) return n + } + return null +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) +} + +function toIsoMs(value: unknown): number | null { + if (typeof value !== "string" || value === "") return null + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null +} + +/** Next epoch-aligned boundary for fixed-period windows (Ollama Cloud resets). */ +function nextBoundaryMs(nowMs: number, periodSeconds: number): number { + const periodMs = periodSeconds * 1_000 + return (Math.floor(nowMs / periodMs) + 1) * periodMs +} + +/** First day of the next UTC calendar month (Kimi monthly cap has no reset field). */ +function nextMonthBoundaryMs(nowMs: number): number { + const d = new Date(nowMs) + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) +} + +function windowLabel(seconds: number): { id: string; shortLabel: string; label: string } { + if (seconds === FIVE_HOURS_SECONDS) return { id: "5h", shortLabel: "5H", label: "5-hour rolling" } + if (seconds === WEEK_SECONDS) return { id: "weekly", shortLabel: "WK", label: "Weekly" } + if (seconds === GO_MONTH_SECONDS) return { id: "monthly", shortLabel: "MO", label: "Monthly" } + const hours = Math.round(seconds / HOUR_SECONDS) + if (hours < 48) return { id: `${hours}h`, shortLabel: `${hours}H`, label: `${hours}-hour window` } + return { id: "weekly", shortLabel: "WK", label: "Weekly" } +} + +async function fetchJson( + fetchImpl: FetchLike, + url: string, + headers: Record, +): Promise { + const res = await fetchImpl(url, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status} from ${new URL(url).host}`) + } + return res.json() as unknown +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +/* ── Pure response parsers (exported for tests) ── */ + +/** OpenCode Go: { usage: { rolling|weekly|monthly: { percent, resetsAt } } } */ +export function parseGoUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const usage = root ? asRecord(root.usage) : null + if (!usage) return [] + const windows: QuotaWindow[] = [] + const defs: Array<{ key: string; id: string; shortLabel: string; label: string }> = [ + { key: "rolling", id: "5h", shortLabel: "5H", label: "5-hour rolling" }, + { key: "weekly", id: "weekly", shortLabel: "WK", label: "Weekly" }, + { key: "monthly", id: "monthly", shortLabel: "MO", label: "Monthly" }, + ] + for (const def of defs) { + const win = asRecord(usage[def.key]) + if (!win) continue + const percent = toNumber(win.percent) + if (percent === null) continue + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: toIsoMs(win.resetsAt), + }) + } + return windows +} + +/** + * Z.AI: { data: { limits: [{ type, unit, number, percentage?, currentValue?, usage?, nextResetTime? }] } } + * (unit=3,number=5) → 5h; (unit=6,number=1) → weekly; TIME_LIMIT → monthly tools budget. + */ +export function parseZaiUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const data = root ? asRecord(root.data) : null + const limits = data && Array.isArray(data.limits) ? data.limits : null + if (!limits) { + // Legacy flat shape: fiveHourPercent / weeklyPercent at data level. + if (!data) return [] + const windows: QuotaWindow[] = [] + const fiveHour = toNumber(data.fiveHourPercent) + if (fiveHour !== null) { + windows.push({ id: "5h", shortLabel: "5H", label: "5-hour rolling", usedPercent: clampPercent(fiveHour), resetsAtMs: null }) + } + const weekly = toNumber(data.weeklyPercent) + if (weekly !== null) { + windows.push({ id: "weekly", shortLabel: "WK", label: "Weekly", usedPercent: clampPercent(weekly), resetsAtMs: null }) + } + return windows + } + const windows: QuotaWindow[] = [] + for (const raw of limits) { + const row = asRecord(raw) + if (!row) continue + let def: { id: string; shortLabel: string; label: string } | null = null + if (row.type === "TIME_LIMIT") { + def = { id: "monthly", shortLabel: "MO", label: "Monthly tools" } + } else if (row.unit === 3 && row.number === 5) { + def = { id: "5h", shortLabel: "5H", label: "5-hour rolling" } + } else if (row.unit === 6 && row.number === 1) { + def = { id: "weekly", shortLabel: "WK", label: "Weekly" } + } + if (!def) continue + let percent = toNumber(row.percentage) + if (percent === null) { + const used = toNumber(row.currentValue) + const total = toNumber(row.usage) + if (used !== null && total !== null && total > 0) percent = (used / total) * 100 + } + if (percent === null) continue + const resetMs = toNumber(row.nextResetTime) + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: resetMs !== null && resetMs > 0 ? resetMs : null, + }) + } + return windows +} + +/** + * Kimi: strings for all numbers. usage → weekly, limits[0] → 5h session, + * totalQuota → monthly membership cap (no reset field → next month boundary). + */ +export function parseKimiUsage(body: unknown, nowMs: number): QuotaWindow[] { + const root = asRecord(body) + if (!root) return [] + const windows: QuotaWindow[] = [] + + const readUsage = (record: Record): { usedPercent: number; resetsAtMs: number | null } | null => { + const limit = toNumber(record.limit) + if (limit === null || limit <= 0) return null + const remaining = toNumber(record.remaining) + const used = toNumber(record.used) + let usedPercent: number + if (remaining !== null) usedPercent = ((limit - remaining) / limit) * 100 + else if (used !== null) usedPercent = (used / limit) * 100 + else return null + const reset = toIsoMs(record.resetTime) ?? toIsoMs(record.reset_at) + return { usedPercent: clampPercent(usedPercent), resetsAtMs: reset } + } + + const usage = asRecord(root.usage) + if (usage) { + const parsed = readUsage(usage) + if (parsed) windows.push({ id: "weekly", shortLabel: "WK", label: "Weekly", ...parsed }) + } + + if (Array.isArray(root.limits)) { + for (const raw of root.limits) { + const entry = asRecord(raw) + if (!entry) continue + const win = asRecord(entry.window) + const duration = win ? toNumber(win.duration) : null + const detail = asRecord(entry.detail) + if (!detail) continue + const parsed = readUsage(detail) + if (!parsed) continue + if (duration === 300) { + windows.push({ id: "5h", shortLabel: "5H", label: "5-hour rolling", ...parsed }) + } else { + windows.push({ id: "window", shortLabel: "WIN", label: "Rate window", ...parsed }) + } + } + } + + const totalQuota = asRecord(root.totalQuota) + if (totalQuota) { + const limit = toNumber(totalQuota.limit) + const used = toNumber(totalQuota.used) ?? (limit !== null && typeof totalQuota.remaining !== "undefined" + ? limit - (toNumber(totalQuota.remaining) ?? 0) + : null) + if (limit !== null && limit > 0 && used !== null) { + windows.push({ + id: "monthly", + shortLabel: "MO", + label: "Monthly cap", + usedPercent: clampPercent((used / limit) * 100), + resetsAtMs: nextMonthBoundaryMs(nowMs), + }) + } + } + return windows +} + +/** ChatGPT (codex): rate_limit.primary_window / secondary_window with used_percent. */ +export function parseOpenAiUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const rateLimit = root ? asRecord(root.rate_limit) : null + if (!rateLimit) return [] + const windows: QuotaWindow[] = [] + for (const key of ["primary_window", "secondary_window"] as const) { + const win = asRecord(rateLimit[key]) + if (!win) continue + const percent = toNumber(win.used_percent) + if (percent === null) continue + const windowSeconds = toNumber(win.limit_window_seconds) ?? FIVE_HOURS_SECONDS + const def = windowLabel(windowSeconds) + const resetAtSec = toNumber(win.reset_at) + const resetAfterSec = toNumber(win.reset_after_seconds) + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: + resetAtSec !== null && resetAtSec > 0 + ? resetAtSec * 1_000 + : resetAfterSec !== null + ? Date.now() + resetAfterSec * 1_000 + : null, + }) + } + return windows +} + +/** Ollama Cloud: limits.{session,weekly}.usage as 0..1 fractions, no reset timestamps. */ +export function parseOllamaUsage(body: unknown, nowMs: number): QuotaWindow[] { + const root = asRecord(body) + const limits = root ? asRecord(root.limits) : null + if (!limits) return [] + const windows: QuotaWindow[] = [] + const defs: Array<{ key: string; id: string; shortLabel: string; label: string; periodSeconds: number }> = [ + { key: "session", id: "5h", shortLabel: "5H", label: "5-hour rolling", periodSeconds: FIVE_HOURS_SECONDS }, + { key: "weekly", id: "weekly", shortLabel: "WK", label: "Weekly", periodSeconds: WEEK_SECONDS }, + ] + for (const def of defs) { + const win = asRecord(limits[def.key]) + if (!win) continue + const fraction = toNumber(win.usage) + if (fraction === null) continue + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(fraction * 100), + resetsAtMs: nextBoundaryMs(nowMs, def.periodSeconds), + }) + } + return windows +} + +/* ── OAuth refresh (in-memory only) ── */ + +type RefreshedToken = { access: string; expiresMs: number } +const refreshedTokens = new Map() + +function isFresh(token: RefreshedToken, nowMs: number): boolean { + return token.expiresMs - 60_000 > nowMs +} + +async function refreshOAuthToken(args: { + providerKey: string + tokenUrl: string + clientId: string + refreshToken: string + fetchImpl: FetchLike +}): Promise { + const cached = refreshedTokens.get(args.providerKey) + const nowMs = Date.now() + if (cached && isFresh(cached, nowMs)) return cached + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: args.refreshToken, + client_id: args.clientId, + }) + const res = await args.fetchImpl(args.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`token refresh failed: HTTP ${res.status}`) + } + const json = asRecord(await res.json() as unknown) + const access = json && typeof json.access_token === "string" ? json.access_token : null + if (!access) throw new Error("token refresh response missing access_token") + const expiresIn = (json !== null ? toNumber(json.expires_in) : null) ?? 3_600 + const token: RefreshedToken = { + access, + expiresMs: Date.now() + expiresIn * 1_000, + } + refreshedTokens.set(args.providerKey, token) + return token +} + +/** Resolve a usable bearer token for an oauth entry, refreshing when expired. */ +async function resolveOauthAccessToken(args: { + providerKey: string + entry: AuthEntryOauth + tokenUrl: string + clientId: string + fetchImpl: FetchLike +}): Promise { + const nowMs = Date.now() + const cached = refreshedTokens.get(args.providerKey) + if (cached && isFresh(cached, nowMs)) return cached.access + if (args.entry.expiresMs - 60_000 > nowMs) return args.entry.access + const token = await refreshOAuthToken({ + providerKey: args.providerKey, + tokenUrl: args.tokenUrl, + clientId: args.clientId, + refreshToken: args.entry.refresh, + fetchImpl: args.fetchImpl, + }) + return token.access +} + +/* ── Provider registry ── */ + +type ProviderFetchArgs = { + auth: AuthFile + fetchImpl: FetchLike + nowMs: number +} + +type ProviderDef = { + providerId: string + name: string + symbol: string + /** auth.json keys to probe in order; first present wins */ + authKeys: string[] + fetchWindows: (args: ProviderFetchArgs & { entry: AuthEntry }) => Promise +} + +const providerDefs: ProviderDef[] = [ + { + providerId: "opencode-go", + name: "OpenCode Go", + symbol: "GO", + authKeys: ["opencode-go"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "api") throw new Error("opencode-go auth entry is not an API key") + const body = await fetchJson(fetchImpl, GO_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseGoUsage(body) + }, + }, + { + providerId: "zai-coding-plan", + name: "Z.AI", + symbol: "Z", + authKeys: ["zai-coding-plan"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "api") throw new Error("zai-coding-plan auth entry is not an API key") + const body = await fetchJson(fetchImpl, ZAI_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseZaiUsage(body) + }, + }, + { + providerId: "kimi", + name: "Kimi", + symbol: "KI", + authKeys: ["kimi-code", "kimi-for-coding-oauth", "moonshot"], + fetchWindows: async ({ entry, fetchImpl, nowMs }) => { + let token: string + if (entry.type === "oauth") { + token = await resolveOauthAccessToken({ + providerKey: "kimi-for-coding-oauth", + entry, + tokenUrl: KIMI_TOKEN_URL, + clientId: KIMI_CLIENT_ID, + fetchImpl, + }) + } else { + token = entry.key + } + const body = await fetchJson(fetchImpl, KIMI_USAGE_URL, { + Authorization: `Bearer ${token}`, + }) + return parseKimiUsage(body, nowMs) + }, + }, + { + providerId: "openai", + name: "ChatGPT", + symbol: "GP", + authKeys: ["openai"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "oauth") throw new Error("openai auth entry is not OAuth") + const token = await resolveOauthAccessToken({ + providerKey: "openai", + entry, + tokenUrl: OPENAI_TOKEN_URL, + clientId: OPENAI_CLIENT_ID, + fetchImpl, + }) + const headers: Record = { + Authorization: `Bearer ${token}`, + "User-Agent": "omo-pulse", + } + if (entry.accountId) headers["ChatGPT-Account-Id"] = entry.accountId + const body = await fetchJson(fetchImpl, OPENAI_USAGE_URL, headers) + return parseOpenAiUsage(body) + }, + }, + { + providerId: "ollama-cloud", + name: "Ollama Cloud", + symbol: "OL", + authKeys: ["ollama-cloud"], + fetchWindows: async ({ entry, fetchImpl, nowMs }) => { + if (entry.type !== "api") throw new Error("ollama-cloud auth entry is not an API key") + const body = await fetchJson(fetchImpl, OLLAMA_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseOllamaUsage(body, nowMs) + }, + }, +] + +/* ── Service ── */ + +export type QuotaService = { + getQuotas: () => Promise + invalidate: () => void +} + +export type QuotaServiceOptions = { + authPath?: string + cacheTtlMs?: number + fetchImpl?: FetchLike + now?: () => number +} + +export function defaultAuthPath(): string { + return join(getDataDir(), "opencode", "auth.json") +} + +export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService { + const authPath = opts.authPath ?? defaultAuthPath() + const cacheTtlMs = opts.cacheTtlMs ?? CACHE_TTL_MS + const fetchImpl = opts.fetchImpl ?? fetch + const now = opts.now ?? (() => Date.now()) + + let cache: { atMs: number; payload: ProviderQuotasPayload } | null = null + let inFlight: Promise | null = null + + const fetchOne = async (def: ProviderDef, auth: AuthFile): Promise => { + const fetchedAtMs = now() + let entry: AuthEntry | undefined + for (const key of def.authKeys) { + const candidate = auth[key] + if (candidate) { + entry = candidate + break + } + } + if (!entry) { + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + windows: [], + status: "unconfigured", + fetchedAtMs, + } + } + try { + const windows = await def.fetchWindows({ auth, entry, fetchImpl, nowMs: fetchedAtMs }) + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + windows, + status: "ok", + fetchedAtMs, + } + } catch (err) { + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + windows: [], + status: "error", + error: err instanceof Error ? err.message : String(err), + fetchedAtMs, + } + } + } + + const refresh = async (): Promise => { + let auth: AuthFile = {} + try { + auth = parseAuthFile(await readFile(authPath, "utf8")) + } catch { + // Missing/unreadable auth.json → every provider reports unconfigured. + } + const providers = await Promise.all(providerDefs.map((def) => fetchOne(def, auth))) + return { providers, serverNowMs: now() } + } + + const getQuotas = async (): Promise => { + const currentMs = now() + if (cache && currentMs - cache.atMs < cacheTtlMs) return cache.payload + if (inFlight) return inFlight + inFlight = refresh() + .then((payload) => { + cache = { atMs: now(), payload } + return payload + }) + .finally(() => { + inFlight = null + }) + return inFlight + } + + return { + getQuotas, + invalidate: () => { + cache = null + }, + } +} diff --git a/src/types.ts b/src/types.ts index f3f7a8f..649484a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -261,3 +261,35 @@ export type TelegramServiceStatus = { lastError: string | null alertsSent: number } + +/** Single quota window for a provider (e.g. 5-hour rolling, weekly, monthly) */ +export type QuotaWindow = { + /** Stable window id: "5h" | "weekly" | "monthly" */ + id: string + /** Micro label shown next to the usage line: "5H", "WK", "MO" */ + shortLabel: string + /** Human label for tooltips: "5-hour rolling" */ + label: string + /** 0..100 percent used */ + usedPercent: number + /** Next reset as epoch ms, null when the provider does not report one */ + resetsAtMs: number | null +} + +/** Quota state for a single provider */ +export type ProviderQuota = { + providerId: string + name: string + /** 1-2 char monogram shown in the strip */ + symbol: string + windows: QuotaWindow[] + status: "ok" | "unconfigured" | "error" + error?: string + fetchedAtMs: number +} + +/** Payload for GET /api/quotas */ +export type ProviderQuotasPayload = { + providers: ProviderQuota[] + serverNowMs: number +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1a524f2..15a4f8f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -14,6 +14,7 @@ import { SessionSwimlane } from "./components/SessionSwimlane" import { SettingsPanel } from "./components/SettingsPanel" import { ColumnResizeHandle } from "./components/ColumnResizeHandle" import { ProjectManagementPanel } from "./components/ProjectManagementPanel" +import { QuotaStrip } from "./components/QuotaStrip" import { useStripConfig } from "./hooks/useStripConfig" import { PreviewNav } from "./components/PreviewNav" import type { PreviewMode } from "./types" @@ -22,6 +23,7 @@ import "./App.css" import { useExpandState } from "./hooks/useExpandState" import { useDensityMode } from "./hooks/useDensityMode" import { useSoundNotifications } from "./hooks/useSoundNotifications" +import { useQuotas } from "./hooks/useQuotas" import { useProjectOrder } from "./hooks/useProjectOrder" import { useProjectVisibility } from "./hooks/useProjectVisibility" import { ATTENTION_FIRST_PRIORITY } from "../ingest/status-rollup" @@ -157,6 +159,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { orderedIds, columns, reorder, setColumns, syncIds } = useProjectOrder() const { visibility, isVisible, toggleVisibility } = useProjectVisibility() const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode, setMiniSparklineMode } = useStripConfig() + const { quotas } = useQuotas() const [activeOverlay, setActiveOverlay] = useState('none') /* ── Zoom ── */ @@ -430,6 +433,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onZoomOut={handleZoomOut} onZoomReset={handleZoomReset} /> +
{data === null ? (
Loading…
diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css new file mode 100644 index 0000000..0d80dd2 --- /dev/null +++ b/src/ui/components/QuotaStrip.css @@ -0,0 +1,92 @@ +/* QuotaStrip — minimalist provider quota usage: tiny monograms + 2px usage lines */ + +.quota-strip { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-3) var(--sp-4); + padding: 2px var(--sp-4) 4px; +} + +.quota-strip__provider { + display: flex; + align-items: center; + gap: var(--sp-1); + cursor: default; +} + +.quota-strip__symbol { + font-family: var(--font-mono); + font-size: 8px; + line-height: 1; + letter-spacing: 0.4px; + color: var(--text-muted); + border: 1px solid var(--border-secondary); + border-radius: 3px; + padding: 1.5px 3px; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.quota-strip__provider:hover .quota-strip__symbol { + color: var(--text-secondary); + border-color: var(--border-primary); +} + +.quota-strip__lines { + display: flex; + align-items: center; + gap: 7px; +} + +.quota-strip__line { + display: flex; + align-items: center; + gap: 3px; +} + +.quota-strip__window { + font-family: var(--font-mono); + font-size: 7px; + line-height: 1; + letter-spacing: 0.3px; + color: var(--text-muted); +} + +.quota-strip__track { + position: relative; + width: 44px; + height: 2px; + border-radius: 1px; + background: var(--border-secondary); + overflow: hidden; +} + +.quota-strip__fill { + height: 100%; + border-radius: 1px; + background: var(--accent-primary); + transition: width var(--transition-normal); +} + +.quota-strip__fill--warn { + background: var(--accent-warning); +} + +.quota-strip__fill--danger { + background: var(--accent-danger); +} + +/* Non-ok providers */ + +.quota-strip__provider[data-status="error"] .quota-strip__symbol { + color: var(--accent-danger); + border-color: var(--accent-danger); +} + +.quota-strip__provider[data-status="unconfigured"] { + opacity: 0.35; +} + +[data-theme="light"] .quota-strip__track { + background: var(--border-primary); +} diff --git a/src/ui/components/QuotaStrip.tsx b/src/ui/components/QuotaStrip.tsx new file mode 100644 index 0000000..d42d747 --- /dev/null +++ b/src/ui/components/QuotaStrip.tsx @@ -0,0 +1,75 @@ +import { memo } from "react" +import type { ProviderQuota, ProviderQuotasPayload, QuotaWindow } from "../../types" +import "./QuotaStrip.css" + +/* ── Helpers ── */ + +function formatReset(ms: number | null): string { + if (ms === null) return "" + const deltaMs = ms - Date.now() + if (deltaMs <= 0) return "resetting" + const minutes = Math.round(deltaMs / 60_000) + if (minutes < 60) return `resets in ${minutes}m` + const hours = Math.floor(minutes / 60) + const restMinutes = minutes % 60 + if (hours < 48) return `resets in ${hours}h${restMinutes > 0 ? ` ${restMinutes}m` : ""}` + return `resets in ${Math.floor(hours / 24)}d` +} + +function usageLevel(percent: number): "ok" | "warn" | "danger" { + if (percent >= 90) return "danger" + if (percent >= 70) return "warn" + return "ok" +} + +function tooltipFor(provider: ProviderQuota): string { + if (provider.status === "unconfigured") return `${provider.name} — not configured` + if (provider.status === "error") return `${provider.name} — ${provider.error ?? "unavailable"}` + const parts = provider.windows.map( + (w: QuotaWindow) => + `${w.label}: ${Math.round(w.usedPercent)}% used${w.resetsAtMs !== null ? `, ${formatReset(w.resetsAtMs)}` : ""}`, + ) + return parts.length > 0 ? `${provider.name} · ${parts.join(" · ")}` : provider.name +} + +/* ── Component ── */ + +export const QuotaStrip = memo(function QuotaStrip({ quotas }: { quotas: ProviderQuotasPayload | null }) { + if (quotas === null || quotas.providers.length === 0) return null + + return ( +
+ {quotas.providers.map((provider: ProviderQuota) => ( +
+ +
+ {provider.status !== "ok" || provider.windows.length === 0 ? ( + + ))} +
+ ) +}) diff --git a/src/ui/hooks/useQuotas.ts b/src/ui/hooks/useQuotas.ts new file mode 100644 index 0000000..2330c95 --- /dev/null +++ b/src/ui/hooks/useQuotas.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react" +import type { ProviderQuotasPayload } from "../../types" + +/** UI poll cadence; the server caches provider responses for ~3 min. */ +const POLL_MS = 60_000 + +export type UseQuotasReturn = { + quotas: ProviderQuotasPayload | null + connected: boolean +} + +/** Polls GET /api/quotas once a minute for provider quota usage. */ +export function useQuotas(): UseQuotasReturn { + const [quotas, setQuotas] = useState(null) + const [connected, setConnected] = useState(false) + const connectedRef = useRef(false) + + useEffect(() => { + const ac = new AbortController() + let stopped = false + let timer: number | undefined + + const poll = async (): Promise => { + try { + const res = await fetch("/api/quotas", { signal: ac.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as Partial & { ok?: boolean } + if (stopped) return + if (!json.ok || !Array.isArray(json.providers)) throw new Error("malformed payload") + setQuotas({ providers: json.providers, serverNowMs: json.serverNowMs ?? Date.now() }) + if (!connectedRef.current) { + connectedRef.current = true + setConnected(true) + } + } catch { + if (stopped || ac.signal.aborted) return + if (connectedRef.current) { + connectedRef.current = false + setConnected(false) + } + } finally { + if (!stopped) { + timer = window.setTimeout(() => { void poll() }, POLL_MS) + } + } + } + + void poll() + + return () => { + stopped = true + ac.abort() + if (timer !== undefined) window.clearTimeout(timer) + } + }, []) + + return { quotas, connected } +} From 96bb6a135a8e5c099ef6705f06285de88d636427 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 12:05:57 +0200 Subject: [PATCH 10/23] feat(ui): inline quota reset countdowns + settings toggle --- src/__tests__/project-strip-status.test.tsx | 1 + src/__tests__/quotas.test.ts | 2 +- src/types.ts | 2 ++ src/ui/App.tsx | 2 +- src/ui/components/QuotaStrip.css | 11 +++++++++ src/ui/components/QuotaStrip.tsx | 27 +++++++++++++++------ src/ui/components/SettingsPanel.tsx | 14 +++++++++++ src/ui/hooks/useStripConfig.ts | 1 + 8 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/__tests__/project-strip-status.test.tsx b/src/__tests__/project-strip-status.test.tsx index a164527..2ff12a1 100644 --- a/src/__tests__/project-strip-status.test.tsx +++ b/src/__tests__/project-strip-status.test.tsx @@ -47,6 +47,7 @@ const baseConfig: StripConfigState = { showAvatar: true, showProjectName: true, stripDisplayMode: "project", + showQuotas: true, } const children = { diff --git a/src/__tests__/quotas.test.ts b/src/__tests__/quotas.test.ts index 19aa6ad..a6ef3f9 100644 --- a/src/__tests__/quotas.test.ts +++ b/src/__tests__/quotas.test.ts @@ -235,7 +235,7 @@ describe("createQuotaService", () => { const fetchImpl: FetchLike = async (input) => { callCount += 1 const url = String(input) - if (url.includes("api.opencode.ai")) return jsonResponse({ message: "denied" }, 403) + if (url.includes("opencode.ai/zen")) return jsonResponse({ message: "denied" }, 403) if (url.includes("api.z.ai")) { return jsonResponse({ data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 22 }] }, diff --git a/src/types.ts b/src/types.ts index 649484a..a94beb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -220,6 +220,8 @@ export type StripConfigState = { showGitWorktrees: boolean showAvatar: boolean showProjectName: boolean + /** Provider quota strip visibility (dashboard header area) */ + showQuotas: boolean stripDisplayMode: "project" | "session" } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 15a4f8f..c8fc574 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -433,7 +433,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onZoomOut={handleZoomOut} onZoomReset={handleZoomReset} /> - + {stripConfig.showQuotas && }
{data === null ? (
Loading…
diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css index 0d80dd2..d38a2cf 100644 --- a/src/ui/components/QuotaStrip.css +++ b/src/ui/components/QuotaStrip.css @@ -68,6 +68,16 @@ transition: width var(--transition-normal); } +.quota-strip__reset { + font-family: var(--font-mono); + font-size: 7px; + line-height: 1; + letter-spacing: 0.3px; + color: var(--text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + .quota-strip__fill--warn { background: var(--accent-warning); } @@ -76,6 +86,7 @@ background: var(--accent-danger); } + /* Non-ok providers */ .quota-strip__provider[data-status="error"] .quota-strip__symbol { diff --git a/src/ui/components/QuotaStrip.tsx b/src/ui/components/QuotaStrip.tsx index d42d747..e6167dc 100644 --- a/src/ui/components/QuotaStrip.tsx +++ b/src/ui/components/QuotaStrip.tsx @@ -4,16 +4,18 @@ import "./QuotaStrip.css" /* ── Helpers ── */ -function formatReset(ms: number | null): string { +function formatRemaining(ms: number | null): string { if (ms === null) return "" const deltaMs = ms - Date.now() - if (deltaMs <= 0) return "resetting" - const minutes = Math.round(deltaMs / 60_000) - if (minutes < 60) return `resets in ${minutes}m` + if (deltaMs <= 60_000) return "now" + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) return `${minutes}m` const hours = Math.floor(minutes / 60) const restMinutes = minutes % 60 - if (hours < 48) return `resets in ${hours}h${restMinutes > 0 ? ` ${restMinutes}m` : ""}` - return `resets in ${Math.floor(hours / 24)}d` + if (hours < 48) return restMinutes > 0 ? `${hours}h${restMinutes}m` : `${hours}h` + const days = Math.floor(hours / 24) + const restHours = hours % 24 + return restHours > 0 ? `${days}d${restHours}h` : `${days}d` } function usageLevel(percent: number): "ok" | "warn" | "danger" { @@ -26,8 +28,12 @@ function tooltipFor(provider: ProviderQuota): string { if (provider.status === "unconfigured") return `${provider.name} — not configured` if (provider.status === "error") return `${provider.name} — ${provider.error ?? "unavailable"}` const parts = provider.windows.map( - (w: QuotaWindow) => - `${w.label}: ${Math.round(w.usedPercent)}% used${w.resetsAtMs !== null ? `, ${formatReset(w.resetsAtMs)}` : ""}`, + (w: QuotaWindow) => { + const remaining = formatRemaining(w.resetsAtMs) + const resetText = + w.resetsAtMs === null ? "" : remaining === "now" ? "resetting" : `, resets in ${remaining}` + return `${w.label}: ${Math.round(w.usedPercent)}% used${resetText}` + }, ) return parts.length > 0 ? `${provider.name} · ${parts.join(" · ")}` : provider.name } @@ -64,6 +70,11 @@ export const QuotaStrip = memo(function QuotaStrip({ quotas }: { quotas: Provide style={{ width: `${Math.max(2, Math.round(w.usedPercent))}%` }} />
+ {w.resetsAtMs !== null && ( + + )}
)) )} diff --git a/src/ui/components/SettingsPanel.tsx b/src/ui/components/SettingsPanel.tsx index 0dfb011..47c932d 100644 --- a/src/ui/components/SettingsPanel.tsx +++ b/src/ui/components/SettingsPanel.tsx @@ -288,6 +288,20 @@ export function SettingsPanel({
))} +

Dashboard

+
+ Provider Quotas +
+ {/* Collapsed Pane Height */}
Collapsed Height diff --git a/src/ui/hooks/useStripConfig.ts b/src/ui/hooks/useStripConfig.ts index a26b2eb..6ed0a5d 100644 --- a/src/ui/hooks/useStripConfig.ts +++ b/src/ui/hooks/useStripConfig.ts @@ -15,6 +15,7 @@ const DEFAULT_CONFIG: StripConfigState = { showGitWorktrees: true, showAvatar: true, showProjectName: true, + showQuotas: true, stripDisplayMode: "project", } From 811810fc30f711971874f33c0413e8d8bc55e324 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 14:20:27 +0200 Subject: [PATCH 11/23] feat(ui): provider favicon identifiers with icons/codes toggle, density-matched strip typography --- src/__tests__/project-strip-status.test.tsx | 1 + src/__tests__/quotas.test.ts | 28 +++++++ src/server/quotas.ts | 82 ++++++++++++++++++++- src/types.ts | 6 +- src/ui/App.tsx | 5 +- src/ui/components/QuotaStrip.css | 67 ++++++++++++----- src/ui/components/QuotaStrip.tsx | 25 ++++++- src/ui/components/SettingsPanel.tsx | 30 ++++++++ src/ui/hooks/useStripConfig.ts | 11 ++- 9 files changed, 226 insertions(+), 29 deletions(-) diff --git a/src/__tests__/project-strip-status.test.tsx b/src/__tests__/project-strip-status.test.tsx index 2ff12a1..da0f53a 100644 --- a/src/__tests__/project-strip-status.test.tsx +++ b/src/__tests__/project-strip-status.test.tsx @@ -48,6 +48,7 @@ const baseConfig: StripConfigState = { showProjectName: true, stripDisplayMode: "project", showQuotas: true, + quotaIconMode: "icons", } const children = { diff --git a/src/__tests__/quotas.test.ts b/src/__tests__/quotas.test.ts index a6ef3f9..7272b92 100644 --- a/src/__tests__/quotas.test.ts +++ b/src/__tests__/quotas.test.ts @@ -314,4 +314,32 @@ describe("createQuotaService", () => { expect(ollama?.windows.map((w) => w.id)).toContain("5h") expect(typeof ollama?.symbol).toBe("string") }) + + it("fetches favicons as data URIs, sniffs missing content-types, and degrades to null", async () => { + const authPath = await withTempAuth( + JSON.stringify({ "ollama-cloud": { type: "api", key: "sk-ol" } }), + ) + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const icoBytes = Buffer.from([0x00, 0x00, 0x01, 0x00, 0x03, 0x00]) + const fetchImpl: FetchLike = async (input) => { + const url = String(input) + if (url.includes("ollama.com/public/icon")) { + return new Response(pngBytes, { status: 200, headers: { "Content-Type": "image/png" } }) + } + if (url.includes("kimi.com/favicon")) { + // Kimi serves a real ICO without a content-type header. + return new Response(icoBytes, { status: 200 }) + } + return jsonResponse({}) + } + const service = createQuotaService({ authPath, fetchImpl }) + const payload = await service.getQuotas() + const ollama = payload.providers.find((p) => p.providerId === "ollama-cloud") + expect(ollama?.icon?.startsWith("data:image/png;base64,")).toBe(true) + const kimi = payload.providers.find((p) => p.providerId === "kimi") + expect(kimi?.icon?.startsWith("data:image/x-icon;base64,")).toBe(true) + const zai = payload.providers.find((p) => p.providerId === "zai-coding-plan") + expect(zai?.icon).toBeNull() + expect(zai?.status).toBe("unconfigured") + }) }) diff --git a/src/server/quotas.ts b/src/server/quotas.ts index 2e3c4e4..58a1811 100644 --- a/src/server/quotas.ts +++ b/src/server/quotas.ts @@ -442,10 +442,76 @@ type ProviderFetchArgs = { nowMs: number } +/* ── Provider icons (server-fetched favicons, cached in memory) ── */ + +const ICON_TTL_MS = 7 * 24 * 3_600_000 +const ICON_FAILURE_TTL_MS = 3_600_000 +const ICON_MAX_BYTES = 100_000 + +type CachedIcon = { dataUri: string | null; atMs: number } + +async function fetchProviderIcon(args: { + providerId: string + iconUrl: string + fetchImpl: FetchLike + cache: Map +}): Promise { + const cached = args.cache.get(args.providerId) + const nowMs = Date.now() + if (cached) { + const ttl = cached.dataUri === null ? ICON_FAILURE_TTL_MS : ICON_TTL_MS + if (nowMs - cached.atMs < ttl) return cached.dataUri + } + try { + const res = await args.fetchImpl(args.iconUrl, { + signal: AbortSignal.timeout(5_000), + }) + const rawMime = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() + const buf = await res.arrayBuffer() + const mime = sniffImageMime(buf, rawMime) + if (!res.ok || mime === null) { + throw new Error(`favicon is not an image (${rawMime || "no content type"})`) + } + if (buf.byteLength === 0 || buf.byteLength > ICON_MAX_BYTES) { + throw new Error(`favicon size out of range (${buf.byteLength} bytes)`) + } + const dataUri = `data:${mime};base64,${Buffer.from(buf).toString("base64")}` + args.cache.set(args.providerId, { dataUri, atMs: nowMs }) + return dataUri + } catch { + // Icon stays unavailable; letter codes remain the fallback in the UI. + args.cache.set(args.providerId, { dataUri: null, atMs: nowMs }) + return null + } +} + +/** Identify an image from magic bytes when the server omits/misstates content-type. */ +function sniffImageMime(buf: ArrayBuffer, declaredMime: string): string | null { + if (declaredMime.startsWith("image/")) return declaredMime + const b = new Uint8Array(buf) + if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png" + if (b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00) return "image/x-icon" + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg" + if ( + b.length >= 12 && + String.fromCharCode(...b.slice(0, 4)) === "RIFF" && + String.fromCharCode(...b.slice(8, 12)) === "WEBP" + ) { + return "image/webp" + } + const head = new TextDecoder().decode(b.slice(0, 200)).trim().toLowerCase() + if (head.startsWith(" Promise @@ -456,6 +522,7 @@ const providerDefs: ProviderDef[] = [ providerId: "opencode-go", name: "OpenCode Go", symbol: "GO", + iconUrl: "https://opencode.ai/favicon.ico", authKeys: ["opencode-go"], fetchWindows: async ({ entry, fetchImpl }) => { if (entry.type !== "api") throw new Error("opencode-go auth entry is not an API key") @@ -469,6 +536,7 @@ const providerDefs: ProviderDef[] = [ providerId: "zai-coding-plan", name: "Z.AI", symbol: "Z", + iconUrl: "https://z.ai/favicon.png", authKeys: ["zai-coding-plan"], fetchWindows: async ({ entry, fetchImpl }) => { if (entry.type !== "api") throw new Error("zai-coding-plan auth entry is not an API key") @@ -482,6 +550,7 @@ const providerDefs: ProviderDef[] = [ providerId: "kimi", name: "Kimi", symbol: "KI", + iconUrl: "https://www.kimi.com/favicon.ico", authKeys: ["kimi-code", "kimi-for-coding-oauth", "moonshot"], fetchWindows: async ({ entry, fetchImpl, nowMs }) => { let token: string @@ -506,6 +575,7 @@ const providerDefs: ProviderDef[] = [ providerId: "openai", name: "ChatGPT", symbol: "GP", + iconUrl: "https://chatgpt.com/favicon.ico", authKeys: ["openai"], fetchWindows: async ({ entry, fetchImpl }) => { if (entry.type !== "oauth") throw new Error("openai auth entry is not OAuth") @@ -529,6 +599,7 @@ const providerDefs: ProviderDef[] = [ providerId: "ollama-cloud", name: "Ollama Cloud", symbol: "OL", + iconUrl: "https://ollama.com/public/icon-32x32.png", authKeys: ["ollama-cloud"], fetchWindows: async ({ entry, fetchImpl, nowMs }) => { if (entry.type !== "api") throw new Error("ollama-cloud auth entry is not an API key") @@ -563,12 +634,18 @@ export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService const cacheTtlMs = opts.cacheTtlMs ?? CACHE_TTL_MS const fetchImpl = opts.fetchImpl ?? fetch const now = opts.now ?? (() => Date.now()) - + const iconCache = new Map() let cache: { atMs: number; payload: ProviderQuotasPayload } | null = null let inFlight: Promise | null = null const fetchOne = async (def: ProviderDef, auth: AuthFile): Promise => { const fetchedAtMs = now() + const iconPromise = fetchProviderIcon({ + providerId: def.providerId, + iconUrl: def.iconUrl, + fetchImpl, + cache: iconCache, + }) let entry: AuthEntry | undefined for (const key of def.authKeys) { const candidate = auth[key] @@ -582,6 +659,7 @@ export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService providerId: def.providerId, name: def.name, symbol: def.symbol, + icon: await iconPromise, windows: [], status: "unconfigured", fetchedAtMs, @@ -593,6 +671,7 @@ export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService providerId: def.providerId, name: def.name, symbol: def.symbol, + icon: await iconPromise, windows, status: "ok", fetchedAtMs, @@ -602,6 +681,7 @@ export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService providerId: def.providerId, name: def.name, symbol: def.symbol, + icon: await iconPromise, windows: [], status: "error", error: err instanceof Error ? err.message : String(err), diff --git a/src/types.ts b/src/types.ts index a94beb5..2678f44 100644 --- a/src/types.ts +++ b/src/types.ts @@ -222,6 +222,8 @@ export type StripConfigState = { showProjectName: boolean /** Provider quota strip visibility (dashboard header area) */ showQuotas: boolean + /** Provider quota strip identifier style: fetched favicons or letter codes */ + quotaIconMode: "icons" | "codes" stripDisplayMode: "project" | "session" } @@ -282,8 +284,10 @@ export type QuotaWindow = { export type ProviderQuota = { providerId: string name: string - /** 1-2 char monogram shown in the strip */ + /** 1-2 char monogram shown in the strip (fallback when no icon) */ symbol: string + /** Provider favicon as a data URI, null when unavailable */ + icon: string | null windows: QuotaWindow[] status: "ok" | "unconfigured" | "error" error?: string diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c8fc574..365777e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -158,7 +158,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { config: soundConfig, setConfig: setSoundConfig, playWaiting, playAllClear, playAttention, playQuestion } = useSoundNotifications() const { orderedIds, columns, reorder, setColumns, syncIds } = useProjectOrder() const { visibility, isVisible, toggleVisibility } = useProjectVisibility() - const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode, setMiniSparklineMode } = useStripConfig() + const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode, setMiniSparklineMode, setQuotaIconMode } = useStripConfig() const { quotas } = useQuotas() const [activeOverlay, setActiveOverlay] = useState('none') @@ -433,7 +433,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onZoomOut={handleZoomOut} onZoomReset={handleZoomReset} /> - {stripConfig.showQuotas && } + {stripConfig.showQuotas && }
{data === null ? (
Loading…
@@ -510,6 +510,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onToggleStrip={toggleStripConfig} onSetStripMode={setStripMode} onSetMiniSparklineMode={setMiniSparklineMode} + onSetQuotaIconMode={setQuotaIconMode} soundConfig={soundConfig} onSoundConfigChange={setSoundConfig} onTestSound={handleTestSound} diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css index d38a2cf..3ec09a1 100644 --- a/src/ui/components/QuotaStrip.css +++ b/src/ui/components/QuotaStrip.css @@ -1,52 +1,79 @@ -/* QuotaStrip — minimalist provider quota usage: tiny monograms + 2px usage lines */ +/* QuotaStrip — minimalist provider quota usage: monograms + thin usage lines */ .quota-strip { + /* Identifier + line scale; mirrors ProjectStrip title sizes per density */ + --quota-font: var(--font-sm); + --quota-icon: 16px; + --quota-track-w: 72px; + --quota-track-h: 3px; display: flex; align-items: center; flex-wrap: wrap; - gap: var(--sp-3) var(--sp-4); - padding: 2px var(--sp-4) 4px; + gap: var(--sp-2) var(--sp-5); + padding: 3px var(--sp-4) 6px; +} + +[data-density="dense"] .quota-strip { + --quota-font: var(--font-xs); + --quota-icon: 14px; + --quota-track-w: 56px; + --quota-track-h: 2px; +} + +[data-density="ultra-dense"] .quota-strip { + --quota-font: 0.6rem; + --quota-icon: 12px; + --quota-track-w: 44px; + --quota-track-h: 2px; } .quota-strip__provider { display: flex; align-items: center; - gap: var(--sp-1); + gap: var(--sp-2); cursor: default; } .quota-strip__symbol { font-family: var(--font-mono); - font-size: 8px; + font-size: var(--quota-font); line-height: 1; - letter-spacing: 0.4px; + letter-spacing: 0.5px; color: var(--text-muted); border: 1px solid var(--border-secondary); - border-radius: 3px; - padding: 1.5px 3px; + border-radius: var(--radius-sm); + padding: 2px 4px; transition: color var(--transition-fast), border-color var(--transition-fast); } -.quota-strip__provider:hover .quota-strip__symbol { - color: var(--text-secondary); - border-color: var(--border-primary); +.quota-strip__icon { + width: var(--quota-icon); + height: var(--quota-icon); + border-radius: var(--radius-sm); + object-fit: contain; + filter: saturate(0.85); + transition: filter var(--transition-fast); +} + +.quota-strip__provider:hover .quota-strip__icon { + filter: none; } .quota-strip__lines { display: flex; align-items: center; - gap: 7px; + gap: var(--sp-2); } .quota-strip__line { display: flex; align-items: center; - gap: 3px; + gap: 5px; } .quota-strip__window { font-family: var(--font-mono); - font-size: 7px; + font-size: var(--quota-font); line-height: 1; letter-spacing: 0.3px; color: var(--text-muted); @@ -54,25 +81,25 @@ .quota-strip__track { position: relative; - width: 44px; - height: 2px; - border-radius: 1px; + width: var(--quota-track-w); + height: var(--quota-track-h); + border-radius: calc(var(--quota-track-h) / 2); background: var(--border-secondary); overflow: hidden; } .quota-strip__fill { height: 100%; - border-radius: 1px; + border-radius: 1.5px; background: var(--accent-primary); transition: width var(--transition-normal); } .quota-strip__reset { font-family: var(--font-mono); - font-size: 7px; + font-size: var(--quota-font); line-height: 1; - letter-spacing: 0.3px; + letter-spacing: 0.2px; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; diff --git a/src/ui/components/QuotaStrip.tsx b/src/ui/components/QuotaStrip.tsx index e6167dc..40c04b4 100644 --- a/src/ui/components/QuotaStrip.tsx +++ b/src/ui/components/QuotaStrip.tsx @@ -40,9 +40,16 @@ function tooltipFor(provider: ProviderQuota): string { /* ── Component ── */ -export const QuotaStrip = memo(function QuotaStrip({ quotas }: { quotas: ProviderQuotasPayload | null }) { +export type QuotaStripProps = { + quotas: ProviderQuotasPayload | null + iconMode: "icons" | "codes" +} + +export const QuotaStrip = memo(function QuotaStrip({ quotas, iconMode }: QuotaStripProps) { if (quotas === null || quotas.providers.length === 0) return null + const useIcons = iconMode === "icons" + return (
{quotas.providers.map((provider: ProviderQuota) => ( @@ -52,9 +59,19 @@ export const QuotaStrip = memo(function QuotaStrip({ quotas }: { quotas: Provide data-status={provider.status} title={tooltipFor(provider)} > - + {useIcons && provider.icon ? ( + + ) : ( + + )}
{provider.status !== "ok" || provider.windows.length === 0 ? ( +
+ Quota Identifiers +
+ + +
+
+ {/* Collapsed Pane Height */}
Collapsed Height diff --git a/src/ui/hooks/useStripConfig.ts b/src/ui/hooks/useStripConfig.ts index 6ed0a5d..a914a84 100644 --- a/src/ui/hooks/useStripConfig.ts +++ b/src/ui/hooks/useStripConfig.ts @@ -16,6 +16,7 @@ const DEFAULT_CONFIG: StripConfigState = { showAvatar: true, showProjectName: true, showQuotas: true, + quotaIconMode: "icons", stripDisplayMode: "project", } @@ -57,6 +58,7 @@ export function useStripConfig(): { toggle: (key: keyof StripConfigState) => void setMode: (mode: "project" | "session") => void setMiniSparklineMode: (mode: MiniSparklineMode) => void + setQuotaIconMode: (mode: "icons" | "codes") => void reset: () => void } { const [config, setConfig] = useState(() => readPersistedConfig()) @@ -87,9 +89,16 @@ export function useStripConfig(): { })) }, []) + const setQuotaIconMode = useCallback((mode: "icons" | "codes") => { + setConfig((prev) => ({ + ...prev, + quotaIconMode: mode, + })) + }, []) + const reset = useCallback(() => { setConfig(DEFAULT_CONFIG) }, []) - return { config, toggle, setMode, setMiniSparklineMode, reset } + return { config, toggle, setMode, setMiniSparklineMode, setQuotaIconMode, reset } } From 2ea58d20f682e649a1d95783c0249f5003ea0ff4 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 15:09:06 +0200 Subject: [PATCH 12/23] feat(ui): collapsible header with corner chevron; compact vertical padding --- src/ui/App.css | 31 ++++++++++++++-- src/ui/App.tsx | 52 +++++++++++++++++++-------- src/ui/components/DashboardHeader.tsx | 8 +++++ src/ui/components/QuotaStrip.css | 2 +- 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/ui/App.css b/src/ui/App.css index 7eb4c3f..57957fe 100644 --- a/src/ui/App.css +++ b/src/ui/App.css @@ -12,11 +12,34 @@ max-width: 1400px; width: calc(100% / var(--zoom)); margin: 0 auto; - padding: var(--sp-4) var(--sp-4) var(--sp-8); + padding: 2px var(--sp-4) 6px; transform: scale(var(--zoom)); transform-origin: top center; } + +/* Floating chevron shown when the header is collapsed */ +.header-restore { + position: fixed; + top: 4px; + right: var(--sp-2); + z-index: 20; + font-size: var(--font-md); + line-height: 1; + color: var(--text-muted); + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-sm); + padding: 2px 7px; + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.header-restore:hover { + color: var(--text-secondary); + border-color: var(--border-primary); +} + /* ── Header ── */ .dashboard-header { display: flex; @@ -205,12 +228,16 @@ grid-template-columns: repeat(var(--grid-cols, 1), 1fr); gap: var(--grid-gap, 10px); overflow-y: auto; - max-height: calc(100vh - 96px); /* viewport minus header (~44px) + container padding (52px) */ + max-height: calc(100vh - 56px); /* viewport minus header (~44px) + container vertical padding (~8px) */ scrollbar-gutter: stable; align-items: start; position: relative; } + .page[data-header-collapsed="true"] .project-stack { + max-height: calc(100vh - 14px); +} + .project-stack > * { min-width: 0; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 365777e..d1510d4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -162,6 +162,15 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { quotas } = useQuotas() const [activeOverlay, setActiveOverlay] = useState('none') + /* ── Collapsible header ── */ + const [headerCollapsed, setHeaderCollapsed] = useState(() => safeGetItem('dashboard-header-collapsed') === 'true') + + useEffect(() => { + safeSetItem('dashboard-header-collapsed', String(headerCollapsed)) + }, [headerCollapsed]) + + const handleToggleHeader = useCallback(() => setHeaderCollapsed((c) => !c), []) + /* ── Zoom ── */ const [zoom, setZoom] = useState(() => { const saved = safeGetItem('dashboard-zoom') @@ -418,21 +427,34 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap }, [playWaiting, playAllClear, playAttention, playQuestion]) return ( -
- +
+ {headerCollapsed ? ( + + ) : ( + + )} {stripConfig.showQuotas && }
{data === null ? ( diff --git a/src/ui/components/DashboardHeader.tsx b/src/ui/components/DashboardHeader.tsx index fb9c658..5bf636b 100644 --- a/src/ui/components/DashboardHeader.tsx +++ b/src/ui/components/DashboardHeader.tsx @@ -15,6 +15,7 @@ export type DashboardHeaderProps = { onZoomIn?: () => void onZoomOut?: () => void onZoomReset?: () => void + onCollapse?: () => void } /* ── Helpers ── */ @@ -73,6 +74,7 @@ export const DashboardHeader = memo(function DashboardHeader({ onZoomIn, onZoomOut, onZoomReset, + onCollapse, }: DashboardHeaderProps) { return (
@@ -154,6 +156,12 @@ export const DashboardHeader = memo(function DashboardHeader({ ⚙ )} + + {onCollapse && ( + + )}
) diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css index 3ec09a1..da02c07 100644 --- a/src/ui/components/QuotaStrip.css +++ b/src/ui/components/QuotaStrip.css @@ -8,7 +8,7 @@ --quota-track-h: 3px; display: flex; align-items: center; - flex-wrap: wrap; + padding: 3px var(--sp-4) 0; gap: var(--sp-2) var(--sp-5); padding: 3px var(--sp-4) 6px; } From d33425cac4945226d8e7c8bf8439d6c39556c048 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 15:36:12 +0200 Subject: [PATCH 13/23] fix(ui): restore quota strip wrapping lost in padding edit --- src/ui/components/QuotaStrip.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css index da02c07..1e94b04 100644 --- a/src/ui/components/QuotaStrip.css +++ b/src/ui/components/QuotaStrip.css @@ -8,9 +8,9 @@ --quota-track-h: 3px; display: flex; align-items: center; - padding: 3px var(--sp-4) 0; + flex-wrap: wrap; gap: var(--sp-2) var(--sp-5); - padding: 3px var(--sp-4) 6px; + padding: 3px var(--sp-4) 0; } [data-density="dense"] .quota-strip { From 2ec014acbff45d0ca59921d74d8c505a679186d3 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sat, 12 Sep 2026 15:41:07 +0200 Subject: [PATCH 14/23] fix(ui): decouple quota strip sizing from density mode to stop post-load shrink --- src/ui/components/QuotaStrip.css | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/ui/components/QuotaStrip.css b/src/ui/components/QuotaStrip.css index 1e94b04..c47188f 100644 --- a/src/ui/components/QuotaStrip.css +++ b/src/ui/components/QuotaStrip.css @@ -1,7 +1,8 @@ /* QuotaStrip — minimalist provider quota usage: monograms + thin usage lines */ .quota-strip { - /* Identifier + line scale; mirrors ProjectStrip title sizes per density */ + /* Deliberately density-independent: pane titles shrink in dense modes, the + quota strip keeps a constant readable size so it never reflows after load */ --quota-font: var(--font-sm); --quota-icon: 16px; --quota-track-w: 72px; @@ -12,21 +13,6 @@ gap: var(--sp-2) var(--sp-5); padding: 3px var(--sp-4) 0; } - -[data-density="dense"] .quota-strip { - --quota-font: var(--font-xs); - --quota-icon: 14px; - --quota-track-w: 56px; - --quota-track-h: 2px; -} - -[data-density="ultra-dense"] .quota-strip { - --quota-font: 0.6rem; - --quota-icon: 12px; - --quota-track-w: 44px; - --quota-track-h: 2px; -} - .quota-strip__provider { display: flex; align-items: center; From 20ea9ce338937f6a93a1b0bb88715df045c16afa Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Sun, 13 Sep 2026 09:10:56 +0200 Subject: [PATCH 15/23] feat(ui): quota hierarchy collapse and window sort; provider display order --- src/__tests__/quota-strip.test.ts | 41 +++++++++++++++ src/server/quotas.ts | 78 +++++++++++++-------------- src/ui/components/QuotaStrip.tsx | 87 +++++++++++++++++++------------ 3 files changed, 134 insertions(+), 72 deletions(-) create mode 100644 src/__tests__/quota-strip.test.ts diff --git a/src/__tests__/quota-strip.test.ts b/src/__tests__/quota-strip.test.ts new file mode 100644 index 0000000..ce232cf --- /dev/null +++ b/src/__tests__/quota-strip.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" +import { visibleWindows } from "../ui/components/QuotaStrip" +import type { QuotaWindow } from "../types" + +function win(id: string, usedPercent: number): QuotaWindow { + return { id, shortLabel: id.toUpperCase(), label: id, usedPercent, resetsAtMs: null } +} + +describe("visibleWindows", () => { + it("hides shorter windows when a longer one is exhausted", () => { + const windows = [win("5h", 22), win("weekly", 40), win("monthly", 100)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["monthly"]) + }) + + it("hides only the 5h window when weekly is exhausted", () => { + const windows = [win("5h", 22), win("weekly", 100), win("monthly", 40)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["weekly", "monthly"]) + }) + + it("keeps everything when nothing is exhausted", () => { + const windows = [win("5h", 99), win("weekly", 70), win("monthly", 20)] + expect(visibleWindows(windows)).toEqual(windows) + }) + + it("keeps everything when only the shortest window is exhausted", () => { + const windows = [win("5h", 100), win("weekly", 40), win("monthly", 10)] + expect(visibleWindows(windows)).toEqual(windows) + }) + + it("sorts windows shortest-to-longer regardless of payload order", () => { + const windows = [win("monthly", 100), win("5h", 22), win("weekly", 40)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["monthly"]) + const all = [win("monthly", 10), win("5h", 22), win("weekly", 40)] + expect(visibleWindows(all).map((w) => w.id)).toEqual(["5h", "weekly", "monthly"]) + }) + + it("handles single and empty lists", () => { + expect(visibleWindows([])).toEqual([]) + expect(visibleWindows([win("monthly", 100)])).toEqual([win("monthly", 100)]) + }) +}) diff --git a/src/server/quotas.ts b/src/server/quotas.ts index 58a1811..c01195a 100644 --- a/src/server/quotas.ts +++ b/src/server/quotas.ts @@ -518,20 +518,6 @@ type ProviderDef = { } const providerDefs: ProviderDef[] = [ - { - providerId: "opencode-go", - name: "OpenCode Go", - symbol: "GO", - iconUrl: "https://opencode.ai/favicon.ico", - authKeys: ["opencode-go"], - fetchWindows: async ({ entry, fetchImpl }) => { - if (entry.type !== "api") throw new Error("opencode-go auth entry is not an API key") - const body = await fetchJson(fetchImpl, GO_USAGE_URL, { - Authorization: `Bearer ${entry.key}`, - }) - return parseGoUsage(body) - }, - }, { providerId: "zai-coding-plan", name: "Z.AI", @@ -546,31 +532,6 @@ const providerDefs: ProviderDef[] = [ return parseZaiUsage(body) }, }, - { - providerId: "kimi", - name: "Kimi", - symbol: "KI", - iconUrl: "https://www.kimi.com/favicon.ico", - authKeys: ["kimi-code", "kimi-for-coding-oauth", "moonshot"], - fetchWindows: async ({ entry, fetchImpl, nowMs }) => { - let token: string - if (entry.type === "oauth") { - token = await resolveOauthAccessToken({ - providerKey: "kimi-for-coding-oauth", - entry, - tokenUrl: KIMI_TOKEN_URL, - clientId: KIMI_CLIENT_ID, - fetchImpl, - }) - } else { - token = entry.key - } - const body = await fetchJson(fetchImpl, KIMI_USAGE_URL, { - Authorization: `Bearer ${token}`, - }) - return parseKimiUsage(body, nowMs) - }, - }, { providerId: "openai", name: "ChatGPT", @@ -609,6 +570,45 @@ const providerDefs: ProviderDef[] = [ return parseOllamaUsage(body, nowMs) }, }, + { + providerId: "kimi", + name: "Kimi", + symbol: "KI", + iconUrl: "https://www.kimi.com/favicon.ico", + authKeys: ["kimi-code", "kimi-for-coding-oauth", "moonshot"], + fetchWindows: async ({ entry, fetchImpl, nowMs }) => { + let token: string + if (entry.type === "oauth") { + token = await resolveOauthAccessToken({ + providerKey: "kimi-for-coding-oauth", + entry, + tokenUrl: KIMI_TOKEN_URL, + clientId: KIMI_CLIENT_ID, + fetchImpl, + }) + } else { + token = entry.key + } + const body = await fetchJson(fetchImpl, KIMI_USAGE_URL, { + Authorization: `Bearer ${token}`, + }) + return parseKimiUsage(body, nowMs) + }, + }, + { + providerId: "opencode-go", + name: "OpenCode Go", + symbol: "GO", + iconUrl: "https://opencode.ai/favicon.ico", + authKeys: ["opencode-go"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "api") throw new Error("opencode-go auth entry is not an API key") + const body = await fetchJson(fetchImpl, GO_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseGoUsage(body) + }, + }, ] /* ── Service ── */ diff --git a/src/ui/components/QuotaStrip.tsx b/src/ui/components/QuotaStrip.tsx index 40c04b4..d97cc05 100644 --- a/src/ui/components/QuotaStrip.tsx +++ b/src/ui/components/QuotaStrip.tsx @@ -24,10 +24,28 @@ function usageLevel(percent: number): "ok" | "warn" | "danger" { return "ok" } -function tooltipFor(provider: ProviderQuota): string { +/** + * Display priority per window duration. When a longer window is exhausted + * (100% used), every shorter window collapses: a 5h reset is irrelevant while + * the provider is locked out for another week by the monthly quota. + */ +const WINDOW_RANK: Record = { window: 0, "5h": 0, weekly: 1, monthly: 2 } + +export function visibleWindows(windows: QuotaWindow[]): QuotaWindow[] { + if (windows.length <= 1) return windows + const rankOf = (w: QuotaWindow): number => WINDOW_RANK[w.id] ?? 0 + /* Always display shortest → longest left-to-right, regardless of payload order */ + const sorted = [...windows].sort((a, b) => rankOf(a) - rankOf(b)) + const firstExhausted = sorted.find((w) => w.usedPercent >= 100) + if (!firstExhausted) return sorted + const cutoff = rankOf(firstExhausted) + return sorted.filter((w) => rankOf(w) >= cutoff) +} + +function tooltipFor(provider: ProviderQuota, windows: QuotaWindow[]): string { if (provider.status === "unconfigured") return `${provider.name} — not configured` if (provider.status === "error") return `${provider.name} — ${provider.error ?? "unavailable"}` - const parts = provider.windows.map( + const parts = windows.map( (w: QuotaWindow) => { const remaining = formatRemaining(w.resetsAtMs) const resetText = @@ -52,13 +70,15 @@ export const QuotaStrip = memo(function QuotaStrip({ quotas, iconMode }: QuotaSt return (
- {quotas.providers.map((provider: ProviderQuota) => ( -
+ {quotas.providers.map((provider: ProviderQuota) => { + const windows = visibleWindows(provider.windows) + return ( +
{useIcons && provider.icon ? ( )} -
- {provider.status !== "ok" || provider.windows.length === 0 ? ( -