From e013f78d78d7b8cb1fd6849024a7ce1d90beec2a Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:07:53 +0300 Subject: [PATCH 1/3] fix(dash): lead the session legend with the session title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping the hourly chart by session labelled every series with the project path plus a truncated session id. In a monorepo every series shares that prefix, so the legend reads as half a dozen indistinguishable hex fragments — and telling one application's spend from another is the main reason to open that chart in the first place. The title is already there: SessionSummary.title is parsed from the transcript and the Context tab already displays it. The legend now prefers it, keeps the short id as the suffix so two sessions sharing a title stay distinguishable, and falls back to the existing project label when a session has no title. Titles come from transcripts, so they get the same treatment as model names: ANSI stripped, control characters flattened to spaces, whitespace collapsed, and a length cap so a 200-character title cannot dominate the legend or the tooltip. The label is built once per session rather than per API call, since every input to it is constant for a given series key. Reported in #997. The clickable-legend half of that issue is not included. --- CHANGELOG.md | 1 + src/granular-history.ts | 29 ++++++++++++++- tests/granular-history.test.ts | 67 +++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..5e3f4a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed +- **The session chart legend now leads with the session title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now prefers it, keeps the short session id as the disambiguator for sessions that share a title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/src/granular-history.ts b/src/granular-history.ts index aeecd565..2a4293b3 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -1,3 +1,5 @@ +import stripAnsi from 'strip-ansi' + import type { DateRange, ProjectSummary } from './types.js' const FIFTEEN_MINUTES = 15 @@ -5,6 +7,10 @@ const ONE_HOUR = 60 const ONE_DAY = 24 * 60 const MINUTE_MS = 60 * 1000 const MAX_SERIES_PER_METRIC = 6 +// Keep metadata bounded for both the max-w-40 legend and the tooltip: 80 +// characters preserves a useful title without letting the parser's 200-char +// transcript cap dominate either UI surface. +const MAX_SESSION_TITLE_LENGTH = 80 export type GranularSeries = { id: string @@ -99,6 +105,21 @@ function shortSessionId(sessionId: string): string { return trimmed.length > 12 ? `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}` : trimmed || 'unknown' } +function cleanSessionTitle(title: string | undefined): string | undefined { + if (title === undefined) return undefined + + // Match the control-character range used by the model-name sanitizer. ANSI + // sequences are removed first; remaining controls become spaces so transcript + // line breaks cannot join words before internal whitespace is collapsed. + const cleaned = stripAnsi(title) + .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!cleaned) return undefined + + return cleaned.slice(0, MAX_SESSION_TITLE_LENGTH).trimEnd() || undefined +} + // Legend labels: the sanitized project dir ("-Users-name-Projects-app") is // unreadable, so prefer the real projectPath's last two segments ("app/web"). // Fall back to the sanitized name when no usable path exists. @@ -214,7 +235,13 @@ export function buildGranularHistory( add(modelTotals, modelKey, cost, tokens) add(sessionTotals, sessionKey, cost, tokens) modelLabels.set(modelKey, modelKey === '' ? 'Other model' : modelKey) - sessionLabels.set(sessionKey, `${shortProjectLabel(project.projectPath, projectName)} · ${shortSessionId(session.sessionId)} (${call.provider})`) + // Every input is constant for a given sessionKey (the provider is part + // of the key), so build the label once instead of re-sanitizing the + // title on every call in the session. + if (!sessionLabels.has(sessionKey)) { + const sessionLabel = cleanSessionTitle(session.title) ?? shortProjectLabel(project.projectPath, projectName) + sessionLabels.set(sessionKey, `${sessionLabel} · ${shortSessionId(session.sessionId)} (${call.provider})`) + } callCount++ } } diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts index 9eec5339..d5b94a5a 100644 --- a/tests/granular-history.test.ts +++ b/tests/granular-history.test.ts @@ -45,7 +45,7 @@ function apiCall(options: { } } -function project(sessions: Array<{ id: string; project?: string; calls: ParsedApiCall[] }>): ProjectSummary { +function project(sessions: Array<{ id: string; project?: string; title?: string; calls: ParsedApiCall[] }>): ProjectSummary { return { project: 'demo', projectPath: '/repos/demo', @@ -56,6 +56,7 @@ function project(sessions: Array<{ id: string; project?: string; calls: ParsedAp sessions: sessions.map(session => ({ sessionId: session.id, project: session.project ?? 'demo', + title: session.title, firstTimestamp: session.calls[0]?.timestamp ?? '', lastTimestamp: session.calls.at(-1)?.timestamp ?? '', totalCostUSD: session.calls.reduce((sum, call) => sum + call.costUSD, 0), @@ -107,6 +108,70 @@ describe('granular history', () => { expect(granularBucketMinutes(range(24 * 30))).toBe(1440) }) + it('prefers a sanitised session title and preserves the exact project fallback when it is missing or blank', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([project([ + { id: 'session-titled-123456', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-absent-123457', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-empty-123458', title: '', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-blank-123459', title: ' \t\n ', calls: [apiCall({ timestamp, cost: 1 })] }, + ])], { start, end }, end) + + expect(history.sessionSeries.map(series => series.label)).toEqual([ + 'Refactor billing module · sessio…3456 (claude)', + 'repos/demo · sessio…3457 (claude)', + 'repos/demo · sessio…3458 (claude)', + 'repos/demo · sessio…3459 (claude)', + ]) + }) + + it('keeps identical session titles distinguishable with the short session id', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([project([ + { id: 'session-111111', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-222222', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + ])], { start, end }, end) + + expect(history.sessionSeries.map(series => series.id)).toEqual(['session_0', 'session_1']) + expect(history.sessionSeries.map(series => series.label)).toEqual([ + 'Refactor billing module · sessio…1111 (claude)', + 'Refactor billing module · sessio…2222 (claude)', + ]) + expect(new Set(history.sessionSeries.map(series => series.label)).size).toBe(2) + }) + + it('sanitises control characters and ANSI escapes in session titles', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([project([{ + id: 'session-sanitised-123456', + title: '\x1b[31mRefactor\x1b[0m\t billing\nmodule\x00', + calls: [apiCall({ timestamp, cost: 1 })], + }])], { start, end }, end) + + expect(history.sessionSeries[0]?.label).toBe('Refactor billing module · sessio…3456 (claude)') + expect(history.sessionSeries[0]?.label).not.toContain('\x1b') + expect(history.sessionSeries[0]?.label).not.toContain('\x00') + }) + + it('caps over-long session titles before putting them in the legend label', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([project([{ + id: 'session-long-title-123456', + title: 'x'.repeat(200), + calls: [apiCall({ timestamp, cost: 1 })], + }])], { start, end }, end) + + expect(history.sessionSeries[0]?.label).toBe('x'.repeat(80) + ' · sessio…3456 (claude)') + }) + it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => { const start = new Date('2026-07-15T00:00:00.000Z') const end = new Date('2026-07-15T23:59:59.999Z') From b6a9622e07f99431ff2113b5b21eb963360c12f0 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:54:22 +0300 Subject: [PATCH 2/3] fix(dash): disambiguate session legend labels, and stop $-patterns in the bootstrap Follow-ups to the title change on this branch, all from an adversarial pass over it. Two series could render byte-identical labels. sessionKey is provider/projectPath/sessionId, so the same session id under two project paths is two series -- a session resumed in a different cwd, or two Claude config directories holding the same project slug. The old label led with the project so they stayed apart; leading with the title dropped the only thing separating them. Labels are now built once at the end from per-key inputs: identical base labels get the short project label appended, and the residual case (two worktrees whose last two path segments match) falls back to the full path plus full session id. The label also depended on cache state. The hoist's comment claimed every input was constant per sessionKey, which is false: title and project name are not in the key, so two SessionSummary objects can share a key with different titles and whichever arrived first won. Reproduced through the real parser -- two config dirs, same project slug and session id, one with a title. Title candidates are now collected per key and a valid one is preferred over none, deterministically. The length cap sliced UTF-16 code units, so a title with an astral character on the boundary left a lone high surrogate in the payload. It counts code points now. Separately, and pre-existing rather than introduced here: the web dashboard injects its bootstrap with html.replace(string, string), so $-substitution patterns in the replacement are interpreted. A payload value containing $` or $' expands to the raw document around the match -- which contains -- so the '<' escaping upstream does not stop it. Device, project and model names already reached that sink; session titles only widen the surface. The injection is factored out and uses a replacer function. --- src/granular-history.ts | 103 ++++++++++++++++++++++++++++++--- src/web-dashboard.ts | 6 +- tests/granular-history.test.ts | 66 +++++++++++++++++++++ tests/web-dashboard.test.ts | 15 ++++- 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/granular-history.ts b/src/granular-history.ts index 2a4293b3..7d75e0bb 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -47,6 +47,20 @@ type RawBucket = { sessions: Map } +type SessionLabelInfo = { + provider: string + projectPath: string + projectNames: Set + sessionId: string + titleCandidates: Set +} + +type SessionLabelEntry = { + key: string + info: SessionLabelInfo + baseLabel: string +} + function nonNegative(value: number): number { return Number.isFinite(value) && value > 0 ? value : 0 } @@ -117,7 +131,73 @@ function cleanSessionTitle(title: string | undefined): string | undefined { .trim() if (!cleaned) return undefined - return cleaned.slice(0, MAX_SESSION_TITLE_LENGTH).trimEnd() || undefined + return Array.from(cleaned).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trimEnd() || undefined +} + +function preferredProjectName(projectNames: Set): string { + return [...projectNames].sort()[0] ?? 'Unknown project' +} + +function preferredSessionTitle(titleCandidates: Set): string | undefined { + return [...titleCandidates] + .map(cleanSessionTitle) + .filter((title): title is string => title !== undefined) + .sort()[0] +} + +function buildSessionLabels(inputs: Map): Map { + const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => { + const sessionLabel = preferredSessionTitle(info.titleCandidates) + ?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames)) + return { + key, + info, + baseLabel: `${sessionLabel} · ${shortSessionId(info.sessionId)} (${info.provider})`, + } + }) + const byBaseLabel = new Map() + for (const entry of entries) { + const group = byBaseLabel.get(entry.baseLabel) ?? [] + group.push(entry) + byBaseLabel.set(entry.baseLabel, group) + } + + const labels = new Map() + const usedLabels = new Set() + const setUniqueLabel = (entry: SessionLabelEntry, candidate: string): void => { + let label = candidate + if (usedLabels.has(label)) { + const identity = `${candidate} · ${entry.info.projectPath} · ${entry.info.sessionId}` + label = identity + let suffix = 2 + while (usedLabels.has(label)) label = `${identity} · ${suffix++}` + } + labels.set(entry.key, label) + usedLabels.add(label) + } + for (const group of byBaseLabel.values()) { + if (group.length === 1) { + setUniqueLabel(group[0]!, group[0]!.baseLabel) + continue + } + + const projectLabels = group.map(entry => shortProjectLabel(entry.info.projectPath, preferredProjectName(entry.info.projectNames))) + if (new Set(projectLabels).size === group.length) { + for (let i = 0; i < group.length; i++) { + const entry = group[i]! + setUniqueLabel(entry, `${entry.baseLabel} · ${projectLabels[i]}`) + } + continue + } + + // A short project label can still collide (for example two worktrees with + // the same final path segments). The full path + id is only used for this + // residual collision, and is unique because provider/path/id form the key. + for (const entry of group) { + setUniqueLabel(entry, `${entry.baseLabel} · ${entry.info.projectPath} · ${entry.info.sessionId}`) + } + } + return labels } // Legend labels: the sanitized project dir ("-Users-name-Projects-app") is @@ -205,7 +285,7 @@ export function buildGranularHistory( const modelTotals = new Map() const sessionTotals = new Map() const modelLabels = new Map() - const sessionLabels = new Map() + const sessionLabelInputs = new Map() let callCount = 0 for (const project of projects) { @@ -235,13 +315,19 @@ export function buildGranularHistory( add(modelTotals, modelKey, cost, tokens) add(sessionTotals, sessionKey, cost, tokens) modelLabels.set(modelKey, modelKey === '' ? 'Other model' : modelKey) - // Every input is constant for a given sessionKey (the provider is part - // of the key), so build the label once instead of re-sanitizing the - // title on every call in the session. - if (!sessionLabels.has(sessionKey)) { - const sessionLabel = cleanSessionTitle(session.title) ?? shortProjectLabel(project.projectPath, projectName) - sessionLabels.set(sessionKey, `${sessionLabel} · ${shortSessionId(session.sessionId)} (${call.provider})`) + // Collect raw metadata first. Titles are cleaned once per distinct + // session-key candidate after all calls are aggregated, so a late + // cache title can win without putting sanitisation on the call path. + const labelInfo = sessionLabelInputs.get(sessionKey) ?? { + provider: call.provider, + projectPath: project.projectPath, + projectNames: new Set(), + sessionId: session.sessionId, + titleCandidates: new Set(), } + labelInfo.projectNames.add(projectName) + if (session.title !== undefined) labelInfo.titleCandidates.add(session.title) + sessionLabelInputs.set(sessionKey, labelInfo) callCount++ } } @@ -252,6 +338,7 @@ export function buildGranularHistory( return { bucketMinutes, modelSeries: [], sessionSeries: [], points: [] } } + const sessionLabels = buildSessionLabels(sessionLabelInputs) const modelProjection = projectSeries(rawBuckets, 'models', modelTotals, modelLabels) const sessionProjection = projectSeries(rawBuckets, 'sessions', sessionTotals, sessionLabels) return { diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index fec1f9eb..400e7111 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -90,6 +90,10 @@ function openBrowser(url: string): void { } } +export function injectDashboardBootstrap(html: string, json: string): string { + return html.replace('\n \n ' + + const injected = injectDashboardBootstrap(html, json) + + expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${json}`) + expect(injected).toContain(`"name":"${payloadValue}"`) + }) +}) // Regression guard for the original bug: a bad `period` query used to hit // process.exit(1) and kill the long-running dashboard server. The handlers must From 525b3c1d71742cec360c241aef2d8695d83832d3 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:49:16 +0300 Subject: [PATCH 3/3] fix(dash): keep the disambiguator visible, make the bootstrap safe by construction Three follow-ups from an adversarial pass over this branch. The title cap was sized against the wrong number. 80 code points was chosen "for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the legend renders at text-[10px], which shows roughly 32 characters. Everything past that is clipped -- and that is exactly where the short session id, the provider and every collision-tier suffix lived. Two sessions in one repository whose AI titles share a 32-character prefix rendered as the same legend entry, which is worse than main and is the scenario #997 is about. The label now leads with the disambiguator so it is always inside the visible width, and both the legend and the tooltip carry title= so the full label is reachable on hover. injectDashboardBootstrap was not safe by construction. Extracting the helper fixed the $-substitution problem but left the security-critical '<' escaping at the call site 94 lines away, and the new test called the helper with raw JSON.stringify output -- so deleting that escape left every test green while the served page became injectable through any project, device or model name. Nothing in tests/ asserted that escaping at all. The escaping moves inside the helper, with a test that pushes through a payload value. preferredSessionTitle picked alphabetically, not most recently. types.ts documents title as the last ai-title entry, so when one session id yields two summaries the legend could show the superseded one. It now picks the greatest lastTimestamp, keeping the alphabetical order only to break exact ties so the result stays deterministic. Entries are also ordered by key before the collision tiers run, so the same corpus cannot emit a different label set depending on input order. --- CHANGELOG.md | 2 +- dash/src/components/UsageChart.tsx | 9 +++- src/granular-history.ts | 66 +++++++++++++++++++----- src/web-dashboard.ts | 13 +++-- tests/granular-history.test.ts | 82 +++++++++++++++++++++--------- tests/web-dashboard.test.ts | 28 ++++++++-- 6 files changed, 151 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3f4a53..50a89a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed -- **The session chart legend now leads with the session title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now prefers it, keeps the short session id as the disambiguator for sessions that share a title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) +- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 926409e8..d3c6b17d 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -33,7 +33,12 @@ function makeTooltip(labels: Record, fmt: (n: number) => string, {items.slice(0, 6).map((p: any) => (
- {labels[String(p.dataKey)] ?? String(p.dataKey)} + + {labels[String(p.dataKey)] ?? String(p.dataKey)} + {fmt(p.value)}
))} @@ -172,7 +177,7 @@ function GranularLines({ {series.map(item => ( - {item.label} + {item.label} ))} diff --git a/src/granular-history.ts b/src/granular-history.ts index 7d75e0bb..75b6e328 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -7,9 +7,9 @@ const ONE_HOUR = 60 const ONE_DAY = 24 * 60 const MINUTE_MS = 60 * 1000 const MAX_SERIES_PER_METRIC = 6 -// Keep metadata bounded for both the max-w-40 legend and the tooltip: 80 -// characters preserves a useful title without letting the parser's 200-char -// transcript cap dominate either UI surface. +// Keep metadata bounded for the legend and tooltip: 80 characters preserves a +// useful title without letting the parser's 200-char transcript cap dominate +// either UI surface. const MAX_SESSION_TITLE_LENGTH = 80 export type GranularSeries = { @@ -47,12 +47,17 @@ type RawBucket = { sessions: Map } +type SessionTitleCandidate = { + title: string + lastTimestamp: string +} + type SessionLabelInfo = { provider: string projectPath: string projectNames: Set sessionId: string - titleCandidates: Set + titleCandidates: Map } type SessionLabelEntry = { @@ -134,27 +139,54 @@ function cleanSessionTitle(title: string | undefined): string | undefined { return Array.from(cleaned).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trimEnd() || undefined } +// SessionSummary.lastTimestamp is normally an ISO timestamp, but fixtures and +// older cache entries can be incomplete. Valid timestamps win over invalid +// ones; two invalid values are an exact tie and are resolved alphabetically by +// the caller. +function compareTimestamps(a: string, b: string): number { + const aMs = Date.parse(a) + const bMs = Date.parse(b) + const aValid = Number.isFinite(aMs) + const bValid = Number.isFinite(bMs) + if (aValid && bValid) return aMs - bMs + if (aValid) return 1 + if (bValid) return -1 + return 0 +} + function preferredProjectName(projectNames: Set): string { return [...projectNames].sort()[0] ?? 'Unknown project' } -function preferredSessionTitle(titleCandidates: Set): string | undefined { - return [...titleCandidates] - .map(cleanSessionTitle) - .filter((title): title is string => title !== undefined) - .sort()[0] +function preferredSessionTitle(titleCandidates: Map): string | undefined { + const cleaned = [...titleCandidates.values()] + .map(candidate => { + const title = cleanSessionTitle(candidate.title) + return title === undefined ? undefined : { title, lastTimestamp: candidate.lastTimestamp } + }) + .filter((candidate): candidate is SessionTitleCandidate => candidate !== undefined) + + cleaned.sort((a, b) => { + const timestampOrder = compareTimestamps(b.lastTimestamp, a.lastTimestamp) + if (timestampOrder !== 0) return timestampOrder + return a.title < b.title ? -1 : a.title > b.title ? 1 : 0 + }) + return cleaned[0]?.title } function buildSessionLabels(inputs: Map): Map { + // Stable raw-key order makes the residual used-label guard independent of + // project/session discovery order when a title happens to match another + // label shape. const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => { const sessionLabel = preferredSessionTitle(info.titleCandidates) ?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames)) return { key, info, - baseLabel: `${sessionLabel} · ${shortSessionId(info.sessionId)} (${info.provider})`, + baseLabel: `${shortSessionId(info.sessionId)} (${info.provider}) · ${sessionLabel}`, } - }) + }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0) const byBaseLabel = new Map() for (const entry of entries) { const group = byBaseLabel.get(entry.baseLabel) ?? [] @@ -323,10 +355,18 @@ export function buildGranularHistory( projectPath: project.projectPath, projectNames: new Set(), sessionId: session.sessionId, - titleCandidates: new Set(), + titleCandidates: new Map(), } labelInfo.projectNames.add(projectName) - if (session.title !== undefined) labelInfo.titleCandidates.add(session.title) + if (session.title !== undefined) { + const existingTitle = labelInfo.titleCandidates.get(session.title) + if (!existingTitle || compareTimestamps(session.lastTimestamp, existingTitle.lastTimestamp) > 0) { + labelInfo.titleCandidates.set(session.title, { + title: session.title, + lastTimestamp: session.lastTimestamp, + }) + } + } sessionLabelInputs.set(sessionKey, labelInfo) callCount++ } diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index 400e7111..39416d7b 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -90,8 +90,13 @@ function openBrowser(url: string): void { } } -export function injectDashboardBootstrap(html: string, json: string): string { - return html.replace('\n \n ' - const injected = injectDashboardBootstrap(html, json) + const injected = injectDashboardBootstrap(html, payload) - expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${json}`) + expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${JSON.stringify(payload)}`) expect(injected).toContain(`"name":"${payloadValue}"`) }) + + it('escapes script-closing payload values and preserves the served bootstrap payload', () => { + const hostileName = '' + const payload = { + devices: [{ + id: 'local', + name: hostileName, + payload: { current: { topProjects: [{ name: hostileName }] } }, + }], + } + const html = '' + + const servedHtml = injectDashboardBootstrap(html, payload) + const marker = 'window.__CODEBURN_BOOTSTRAP__=' + const start = servedHtml.indexOf(marker) + marker.length + const end = servedHtml.indexOf('', start) + const serialized = servedHtml.slice(start, end) + + expect(serialized).not.toContain('') + expect(JSON.parse(serialized)).toEqual(payload) + }) }) // Regression guard for the original bug: a bad `period` query used to hit