Skip to content

Commit 07f572a

Browse files
wesmclaude
andauthored
Session continuity via sessionId chaining (#11)
## Summary - Group continuation sessions with their originals via `parent_session_id` chaining, extracted from Claude JSONL `sessionId` fields - Frontend walks `parent_session_id` chains to find root sessions and groups all sessions sharing the same root - Prune command excludes sessions that have children to avoid breaking continuity chains - Old databases (missing `parent_session_id` column) are dropped and rebuilt from scratch on startup ## How it works Claude JSONL files have a `sessionId` field on user/assistant records. For original sessions, `sessionId` matches the file's UUID. For continuations, it carries the parent file's UUID, forming a linked list (A -> B -> C). The parser extracts this and stores it as `parent_session_id`. The frontend walks the chain to find the root and groups all sessions sharing the same root. ## Test plan - [x] `go vet ./... && go test -tags fts5 ./...` -- all pass - [x] `cd frontend && npx vitest run` -- all 318 tests pass - [ ] Manual: delete DB, restart agentsview, verify continuation sessions group under original with correct first message --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8eeb67a commit 07f572a

File tree

16 files changed

+891
-160
lines changed

16 files changed

+891
-160
lines changed

frontend/src/lib/api/types/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface Session {
1515
started_at: string | null;
1616
ended_at: string | null;
1717
message_count: number;
18+
parent_session_id?: string;
1819
file_path?: string;
1920
file_size?: number;
2021
file_mtime?: number;

frontend/src/lib/components/sidebar/SessionItem.svelte

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@
55
66
interface Props {
77
session: Session;
8+
continuationCount?: number;
9+
groupSessionIds?: string[];
810
}
911
10-
let { session }: Props = $props();
11-
12-
let isActive = $derived(sessions.activeSessionId === session.id);
12+
let {
13+
session,
14+
continuationCount = 1,
15+
groupSessionIds,
16+
}: Props = $props();
17+
18+
let isActive = $derived(
19+
groupSessionIds
20+
? groupSessionIds.includes(
21+
sessions.activeSessionId ?? "",
22+
)
23+
: sessions.activeSessionId === session.id,
24+
);
1325
1426
let agentColor = $derived(
1527
session.agent === "codex"
@@ -41,6 +53,9 @@
4153
<span class="session-project">{session.project}</span>
4254
<span class="session-time">{timeStr}</span>
4355
<span class="session-count">{session.message_count}</span>
56+
{#if continuationCount > 1}
57+
<span class="continuation-badge">x{continuationCount}</span>
58+
{/if}
4459
</div>
4560
</div>
4661
</button>
@@ -117,4 +132,12 @@
117132
.session-count::before {
118133
content: "\2022 ";
119134
}
135+
136+
.continuation-badge {
137+
font-size: 9px;
138+
font-weight: 600;
139+
color: var(--accent-blue);
140+
white-space: nowrap;
141+
flex-shrink: 0;
142+
}
120143
</style>

frontend/src/lib/components/sidebar/SessionList.svelte

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
let viewportHeight = $state(0);
1313
let scrollRaf: number | null = $state(null);
1414
15-
let totalCount = $derived(sessions.sessions.length);
15+
let groups = $derived(sessions.groupedSessions);
16+
let totalCount = $derived(groups.length);
1617
1718
let startIndex = $derived(
1819
Math.max(
@@ -110,12 +111,23 @@
110111
style="height: {totalSize}px; width: 100%; position: relative;"
111112
>
112113
{#each virtualRows as row (row.key)}
113-
{@const session = sessions.sessions[row.index]}
114+
{@const group = groups[row.index]}
114115
<div
115116
style="position: absolute; top: 0; left: 0; width: 100%; height: {row.size}px; transform: translateY({row.start}px);"
116117
>
117-
{#if session}
118-
<SessionItem {session} />
118+
{#if group}
119+
{@const primary = group.sessions.find(
120+
(s) => s.id === group.primarySessionId,
121+
) ?? group.sessions[0]}
122+
{#if primary}
123+
<SessionItem
124+
session={primary}
125+
continuationCount={group.sessions.length}
126+
groupSessionIds={group.sessions.length > 1
127+
? group.sessions.map((s) => s.id)
128+
: undefined}
129+
/>
130+
{/if}
119131
{/if}
120132
</div>
121133
{/each}

frontend/src/lib/stores/sessions.svelte.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import type { Session, ProjectInfo } from "../api/types.js";
33

44
const SESSION_PAGE_SIZE = 500;
55

6+
export interface SessionGroup {
7+
key: string;
8+
project: string;
9+
sessions: Session[];
10+
primarySessionId: string;
11+
totalMessages: number;
12+
firstMessage: string | null;
13+
startedAt: string | null;
14+
endedAt: string | null;
15+
}
16+
617
interface Filters {
718
project: string;
819
agent: string;
@@ -44,6 +55,10 @@ class SessionsStore {
4455
);
4556
}
4657

58+
get groupedSessions(): SessionGroup[] {
59+
return buildSessionGroups(this.sessions);
60+
}
61+
4762
private get apiParams() {
4863
const f = this.filters;
4964
return {
@@ -225,4 +240,131 @@ export function createSessionsStore(): SessionsStore {
225240
return new SessionsStore();
226241
}
227242

243+
function maxString(
244+
a: string | null,
245+
b: string | null,
246+
): string | null {
247+
if (a == null) return b;
248+
if (b == null) return a;
249+
return a > b ? a : b;
250+
}
251+
252+
function minString(
253+
a: string | null,
254+
b: string | null,
255+
): string | null {
256+
if (a == null) return b;
257+
if (b == null) return a;
258+
return a < b ? a : b;
259+
}
260+
261+
function recencyKey(s: Session): string {
262+
return s.ended_at ?? s.started_at ?? s.created_at;
263+
}
264+
265+
/**
266+
* Walk parent_session_id chains to find the root session.
267+
* If a link is missing from the loaded set, the walk stops
268+
* there, forming a separate group for each disconnected
269+
* subchain.
270+
*/
271+
function findRoot(
272+
id: string,
273+
byId: Map<string, Session>,
274+
rootCache: Map<string, string>,
275+
): string {
276+
const cached = rootCache.get(id);
277+
if (cached !== undefined) return cached;
278+
279+
// Walk up, capping at set size to guard cycles.
280+
const visited = new Set<string>();
281+
let cur = id;
282+
while (true) {
283+
if (visited.has(cur)) break; // cycle guard
284+
visited.add(cur);
285+
const s = byId.get(cur);
286+
if (!s?.parent_session_id) break;
287+
const parent = s.parent_session_id;
288+
if (!byId.has(parent)) break; // missing link
289+
cur = parent;
290+
}
291+
292+
// cur is the root — cache for every node we visited.
293+
for (const v of visited) {
294+
rootCache.set(v, cur);
295+
}
296+
return cur;
297+
}
298+
299+
export function buildSessionGroups(
300+
sessions: Session[],
301+
): SessionGroup[] {
302+
const byId = new Map<string, Session>();
303+
for (const s of sessions) {
304+
byId.set(s.id, s);
305+
}
306+
307+
const rootCache = new Map<string, string>();
308+
const groupMap = new Map<string, SessionGroup>();
309+
const insertionOrder: string[] = [];
310+
311+
for (const s of sessions) {
312+
const root = findRoot(s.id, byId, rootCache);
313+
// Sessions without a parent_session_id that aren't
314+
// pointed to by anyone get root == their own id, so
315+
// they form a single-session group naturally.
316+
const key = root;
317+
318+
let group = groupMap.get(key);
319+
if (!group) {
320+
group = {
321+
key,
322+
project: s.project,
323+
sessions: [],
324+
primarySessionId: s.id,
325+
totalMessages: 0,
326+
firstMessage: null,
327+
startedAt: null,
328+
endedAt: null,
329+
};
330+
groupMap.set(key, group);
331+
insertionOrder.push(key);
332+
}
333+
334+
group.sessions.push(s);
335+
group.totalMessages += s.message_count;
336+
group.startedAt = minString(
337+
group.startedAt,
338+
s.started_at,
339+
);
340+
group.endedAt = maxString(group.endedAt, s.ended_at);
341+
}
342+
343+
for (const group of groupMap.values()) {
344+
if (group.sessions.length > 1) {
345+
group.sessions.sort((a, b) => {
346+
const ta = a.started_at ?? "";
347+
const tb = b.started_at ?? "";
348+
return ta < tb ? -1 : ta > tb ? 1 : 0;
349+
});
350+
}
351+
group.firstMessage =
352+
group.sessions[0]?.first_message ?? null;
353+
354+
let bestIdx = 0;
355+
let bestKey = recencyKey(group.sessions[0]!);
356+
for (let i = 1; i < group.sessions.length; i++) {
357+
const key = recencyKey(group.sessions[i]!);
358+
if (key > bestKey) {
359+
bestKey = key;
360+
bestIdx = i;
361+
}
362+
}
363+
group.primarySessionId =
364+
group.sessions[bestIdx]!.id;
365+
}
366+
367+
return insertionOrder.map((k) => groupMap.get(k)!);
368+
}
369+
228370
export const sessions = createSessionsStore();

0 commit comments

Comments
 (0)