From c20eb03393f02980e5279f6dc90b71a233f312de Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Sun, 10 May 2026 21:25:51 +0200 Subject: [PATCH 1/7] feat: highlight active session cards by recency Border color tracks `last_seen`: bright green + glow under 30s, green up to 2min, dark green up to 10min, default afterwards. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dashboard.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index a877a42..a42725c 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -266,9 +266,21 @@ export function renderTokens( return html; } +function recencyClass(lastSeen: string | null | undefined): string { + if (!lastSeen) return ""; + const iso = lastSeen.replace(" ", "T") + "Z"; + const ageSec = (Date.now() - Date.parse(iso)) / 1000; + if (Number.isNaN(ageSec) || ageSec < 0) return ""; + if (ageSec < 30) return "session-card--active"; + if (ageSec < 120) return "session-card--recent"; + if (ageSec < 600) return "session-card--idle"; + return ""; +} + function renderSessionCard(s: SessionStats): string { const title = s.title || s.directory?.split("/").pop() || s.session_id; const time = s.last_seen?.replace("T", " ").slice(0, 16) ?? ""; + const recency = recencyClass(s.last_seen); const agentRows = s.agents .map((a) => { @@ -327,7 +339,7 @@ function renderSessionCard(s: SessionStats): string { .join(""); return ` -
+
${esc(title)}
${time}
@@ -687,6 +699,16 @@ function renderHTML( transition: border-color 0.2s; } .session-card:hover { border-color: #388bfd; } + .session-card--active { + border-color: #56d364; + box-shadow: 0 0 0 1px #56d364, 0 0 12px rgba(86, 211, 100, 0.35); + } + .session-card--recent { + border-color: #3fb950; + } + .session-card--idle { + border-color: #1a4d1f; + } .session-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; From 6ea911a4b03aeb021b69cd10a97c2acead6ae87d Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Sun, 10 May 2026 21:35:20 +0200 Subject: [PATCH 2/7] feat: add directory filter dropdown to dashboard sessions panel --- src/dashboard.ts | 88 ++++++++++++++++++++++---- src/db/session/session-repo.ts | 3 +- src/db/session/sqlite-session-repo.ts | 26 ++++++-- tests/unit/sqlite-session-repo.test.ts | 83 ++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 20 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index a42725c..b1eb0cc 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -63,8 +63,8 @@ interface ModeStats { provider_id: string | null; } -function getStats(repos: Repos): SessionStats[] { - const rootSessions = repos.sessions.getRootSessions(); +function getStats(repos: Repos, directory?: string): SessionStats[] { + const rootSessions = repos.sessions.getRootSessions(directory ?? undefined); const childSessions = repos.sessions.getChildSessions(); const agentCalls = repos.toolCalls.getAgentCalls(); const modeRows = repos.messages.getModeStats(); @@ -599,6 +599,8 @@ function renderSessionsFragment( daily: DailyTokens[], dailyModel: DailyModelTokens[], toolGroups: ToolGroupSummary[], + directories: string[], + selectedDir?: string, ): string { const bar = renderStatsBar(summary); const chart = renderDailyChart(daily); @@ -619,9 +621,18 @@ function renderSessionsFragment( ? '
No sessions recorded yet.
' : sessions.map(renderSessionCard).join(""); + const dirOptions = directories.map((d) => ``).join(""); + const dirDropdown = ` +
+ +
`; + const rightPanel = `
-
Sessions
+ ${dirDropdown} ${sessionCards}
`; @@ -634,6 +645,8 @@ function renderHTML( daily: DailyTokens[], dailyModel: DailyModelTokens[], toolGroups: ToolGroupSummary[], + directories: string[], + selectedDir?: string, ): string { return ` @@ -942,14 +955,29 @@ function renderHTML( border-left: 1px solid #21262d; padding-left: 24px; } - .right-panel-title { - font-size: 12px; color: #8b949e; text-transform: uppercase; - letter-spacing: 0.5px; margin-bottom: 12px; - } @media (max-width: 1000px) { .two-col { flex-direction: column; } .left-panel { position: static; } } + #dir-filter { + appearance: none; + -webkit-appearance: none; + background: #161b22 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 12px center; + color: #c9d1d9; + border: 1px solid #30363d; + border-radius: 6px; + padding: 8px 36px 8px 12px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + width: 100%; + transition: border-color 0.2s, box-shadow 0.2s; + } + #dir-filter:hover { border-color: #484f58; } + #dir-filter:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(56,139,253,0.25); } + .filter-bar { + margin-bottom: 16px; + } @@ -962,7 +990,7 @@ function renderHTML(
- ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups)} + ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups, directories, selectedDir)}
`; @@ -1105,7 +1150,9 @@ if (import.meta.main) { } try { - const sessions = getStats(readRepos); + const dirFilter = url.searchParams.get("dir") || undefined; + const directories = readRepos.sessions.getDistinctDirectories(); + const sessions = getStats(readRepos, dirFilter); const summary = getTokenSummary(readRepos); const daily = getDailyTokens(readRepos); const dailyModel = getDailyTokensByModel(readRepos); @@ -1117,6 +1164,8 @@ if (import.meta.main) { daily, dailyModel, toolGroups, + directories, + dirFilter, ), { headers: { "Content-Type": "text/html; charset=utf-8" }, @@ -1129,14 +1178,29 @@ if (import.meta.main) { } } + if (url.pathname === "/api/directories") { + try { + const dirs = readRepos.sessions.getDistinctDirectories(); + return new Response(JSON.stringify(dirs), { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } catch (e) { + return new Response("[]", { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } + } + try { - const sessions = getStats(readRepos); + const dirFilter = url.searchParams.get("dir") || undefined; + const directories = readRepos.sessions.getDistinctDirectories(); + const sessions = getStats(readRepos, dirFilter); const summary = getTokenSummary(readRepos); const daily = getDailyTokens(readRepos); const dailyModel = getDailyTokensByModel(readRepos); const toolGroups = getToolUsageSummary(readRepos); return new Response( - renderHTML(sessions, summary, daily, dailyModel, toolGroups), + renderHTML(sessions, summary, daily, dailyModel, toolGroups, directories, dirFilter), { headers: { "Content-Type": "text/html; charset=utf-8" }, }, diff --git a/src/db/session/session-repo.ts b/src/db/session/session-repo.ts index e459eda..2507f06 100644 --- a/src/db/session/session-repo.ts +++ b/src/db/session/session-repo.ts @@ -40,7 +40,8 @@ export interface ChildSessionRow { export interface SessionRepo { upsert(data: SessionUpsertData): void; upsertFull(data: SessionFullData): void; - getRootSessions(): RootSessionRow[]; + getRootSessions(directory?: string): RootSessionRow[]; getChildSessions(): ChildSessionRow[]; + getDistinctDirectories(): string[]; deleteOrphaned(cutoffDate: string): number; } diff --git a/src/db/session/sqlite-session-repo.ts b/src/db/session/sqlite-session-repo.ts index b7485e3..943fb4f 100644 --- a/src/db/session/sqlite-session-repo.ts +++ b/src/db/session/sqlite-session-repo.ts @@ -43,9 +43,8 @@ export class SqliteSessionRepo implements SessionRepo { ); } - getRootSessions(): RootSessionRow[] { - return this.db - .prepare(` + getRootSessions(directory?: string): RootSessionRow[] { + const baseQuery = ` SELECT s.session_id, s.title, s.directory, s.first_seen, s.last_seen, COALESCE(SUM(m.input_tokens), 0) AS input_tokens, @@ -56,13 +55,26 @@ export class SqliteSessionRepo implements SessionRepo { COALESCE(SUM(m.cost), 0) AS cost FROM sessions s LEFT JOIN messages m ON m.session_id = s.session_id - WHERE s.parent_id IS NULL - GROUP BY s.session_id - ORDER BY s.last_seen DESC - `) + WHERE s.parent_id IS NULL`; + + if (directory) { + return this.db + .prepare(`${baseQuery} AND s.directory = ? GROUP BY s.session_id ORDER BY s.last_seen DESC`) + .all(directory) as RootSessionRow[]; + } + + return this.db + .prepare(`${baseQuery} GROUP BY s.session_id ORDER BY s.last_seen DESC`) .all() as RootSessionRow[]; } + getDistinctDirectories(): string[] { + return this.db + .prepare(`SELECT DISTINCT directory FROM sessions WHERE directory IS NOT NULL ORDER BY directory`) + .all() + .map((row: any) => row.directory as string); + } + getChildSessions(): ChildSessionRow[] { return this.db .prepare(` diff --git a/tests/unit/sqlite-session-repo.test.ts b/tests/unit/sqlite-session-repo.test.ts index e935657..a3129a1 100644 --- a/tests/unit/sqlite-session-repo.test.ts +++ b/tests/unit/sqlite-session-repo.test.ts @@ -55,4 +55,87 @@ describe("sqlite session repo", () => { repos.close(); }); + + test("getRootSessions filters by directory", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsertFull({ + sessionId: "s-a", + projectId: "p1", + parentId: null, + title: "Session A", + directory: "/projects/alpha", + }); + repos.sessions.upsertFull({ + sessionId: "s-b", + projectId: "p2", + parentId: null, + title: "Session B", + directory: "/projects/beta", + }); + + const filtered = repos.sessions.getRootSessions("/projects/alpha"); + expect(filtered.length).toBe(1); + expect(filtered[0]?.session_id).toBe("s-a"); + + const all = repos.sessions.getRootSessions(); + expect(all.length).toBe(2); + + repos.close(); + }); + + test("getDistinctDirectories returns unique sorted directories", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsertFull({ + sessionId: "s-1", + projectId: "p1", + parentId: null, + title: "A", + directory: "/projects/beta", + }); + repos.sessions.upsertFull({ + sessionId: "s-2", + projectId: "p1", + parentId: null, + title: "B", + directory: "/projects/alpha", + }); + repos.sessions.upsertFull({ + sessionId: "s-3", + projectId: "p1", + parentId: null, + title: "C", + directory: "/projects/beta", + }); + + const dirs = repos.sessions.getDistinctDirectories(); + expect(dirs).toEqual(["/projects/alpha", "/projects/beta"]); + + repos.close(); + }); + + test("getDistinctDirectories excludes null directories", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsert({ sessionId: "s-null", projectId: "p1" }); + repos.sessions.upsertFull({ + sessionId: "s-with-dir", + projectId: "p1", + parentId: null, + title: "Has dir", + directory: "/projects/gamma", + }); + + const dirs = repos.sessions.getDistinctDirectories(); + expect(dirs).toEqual(["/projects/gamma"]); + + repos.close(); + }); }); From bd64725e7990912e6686422224dba9b08efe4e36 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Sun, 10 May 2026 21:40:45 +0200 Subject: [PATCH 3/7] fix: apply biome lint and format fixes for CI --- src/dashboard.ts | 21 +++++++++++++++++---- src/db/session/sqlite-session-repo.ts | 8 ++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index b1eb0cc..2588090 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -268,7 +268,7 @@ export function renderTokens( function recencyClass(lastSeen: string | null | undefined): string { if (!lastSeen) return ""; - const iso = lastSeen.replace(" ", "T") + "Z"; + const iso = `${lastSeen.replace(" ", "T")}Z`; const ageSec = (Date.now() - Date.parse(iso)) / 1000; if (Number.isNaN(ageSec) || ageSec < 0) return ""; if (ageSec < 30) return "session-card--active"; @@ -621,7 +621,12 @@ function renderSessionsFragment( ? '
No sessions recorded yet.
' : sessions.map(renderSessionCard).join(""); - const dirOptions = directories.map((d) => ``).join(""); + const dirOptions = directories + .map( + (d) => + ``, + ) + .join(""); const dirDropdown = `