Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 98 additions & 17 deletions apps/ui/src/components/claude-code-sessions-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ClaudeCodeSessionSummary } from '@spiracha/lib/claude-code-exporte
import { Link } from '@tanstack/react-router';
import type { SortingState } from '@tanstack/react-table';
import { createColumnHelper } from '@tanstack/react-table';
import { Download, MoreHorizontal, Trash2 } from 'lucide-react';
import { Download, GitFork, MoreHorizontal, Trash2 } from 'lucide-react';
import { useMemo } from 'react';
import { DataTable } from '#/components/data-table';
import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
Expand All @@ -13,7 +13,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu';
import { formatDateTime, formatNumber, formatTokens } from '#/lib/formatters';
import { formatDateTime, formatModelLabel, formatNumber, formatTokens } from '#/lib/formatters';
import { cn } from '#/lib/utils';

type ClaudeCodeSessionsTableProps = {
onDeleteSession: (session: ClaudeCodeSessionSummary) => void;
Expand All @@ -23,25 +24,100 @@ type ClaudeCodeSessionsTableProps = {
sessions: ClaudeCodeSessionSummary[];
};

const columnHelper = createColumnHelper<ClaudeCodeSessionSummary>();
type ClaudeCodeSessionTreeNode = ClaudeCodeSessionSummary & {
children: ClaudeCodeSessionTreeNode[];
};

const columnHelper = createColumnHelper<ClaudeCodeSessionTreeNode>();
const defaultSorting: SortingState = [{ desc: true, id: 'lastActive' }];

const SessionTitleCell = ({ depth, session }: { depth: number; session: ClaudeCodeSessionTreeNode }) => {
const isSubagent = depth > 0;

return (
<div
className={cn('min-w-0', isSubagent ? 'border-[var(--border)] border-l-2 pl-3' : '')}
data-row-depth={depth}
>
<div className="flex min-w-0 items-center gap-2">
{isSubagent ? (
<GitFork aria-hidden="true" className="size-4 shrink-0 text-[var(--muted-foreground)]" />
) : null}
<Link
className="block min-w-0 flex-1 space-y-1 rounded-md outline-none transition hover:opacity-80 focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
params={{ sessionId: session.sessionId }}
to="/claude-code-sessions/$sessionId"
>
<p className="truncate font-medium underline-offset-2 hover:underline">{session.title}</p>
<p className="truncate text-[var(--muted-foreground)] text-xs">{session.sessionId}</p>
</Link>
</div>
</div>
);
};

const getSessionTreeRoots = (sessions: ClaudeCodeSessionSummary[]): ClaudeCodeSessionTreeNode[] => {
const nodesById = new Map(sessions.map((session) => [session.sessionId, { ...session, children: [] }]));
const childIdsByParentId = new Map<string, string[]>();
const rootIds: string[] = [];

for (const session of sessions) {
const sessionId = session.sessionId;
const parentSessionId = session.hierarchy?.parentSessionId ?? null;
if (!parentSessionId || parentSessionId === sessionId || !nodesById.has(parentSessionId)) {
rootIds.push(sessionId);
continue;
}

const childIds = childIdsByParentId.get(parentSessionId) ?? [];
childIds.push(sessionId);
childIdsByParentId.set(parentSessionId, childIds);
}

const roots: ClaudeCodeSessionTreeNode[] = [];
const attachedSessionIds = new Set<string>();
const attachNode = (sessionId: string, parent: ClaudeCodeSessionTreeNode | null) => {
if (attachedSessionIds.has(sessionId)) {
return;
}

const node = nodesById.get(sessionId);
if (!node) {
return;
}

attachedSessionIds.add(sessionId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}

for (const childSessionId of childIdsByParentId.get(sessionId) ?? []) {
attachNode(childSessionId, node);
}
};

for (const sessionId of rootIds) {
attachNode(sessionId, null);
}
for (const session of sessions) {
attachNode(session.sessionId, null);
}

return roots;
};

const withoutChildren = ({ children: _children, ...session }: ClaudeCodeSessionTreeNode): ClaudeCodeSessionSummary =>
session;

const columns = (
onDeleteSession: (session: ClaudeCodeSessionSummary) => void,
onExportSession: (session: ClaudeCodeSessionSummary) => void,
) =>
[
columnHelper.accessor('title', {
cell: (info) => (
<Link
className="block w-[16rem] max-w-[22rem] space-y-1 rounded-md outline-none transition hover:opacity-80 focus-visible:ring-2 focus-visible:ring-[var(--accent)] lg:w-auto"
params={{ sessionId: info.row.original.sessionId }}
to="/claude-code-sessions/$sessionId"
>
<p className="truncate font-medium underline-offset-2 hover:underline">{info.getValue()}</p>
<p className="truncate text-[var(--muted-foreground)] text-xs">{info.row.original.sessionId}</p>
</Link>
),
cell: (info) => <SessionTitleCell depth={info.row.depth} session={info.row.original} />,
header: 'Session',
}),
columnHelper.accessor('lastActiveAtMs', {
Expand All @@ -54,7 +130,9 @@ const columns = (
id: 'lastActive',
}),
columnHelper.accessor('model', {
cell: (info) => <span className="text-sm">{info.getValue() ?? 'unknown'}</span>,
cell: (info) => (
<span className="text-sm">{info.getValue() ? formatModelLabel(info.getValue()) : 'unknown'}</span>
),
header: 'Model',
}),
columnHelper.accessor('messageCount', {
Expand Down Expand Up @@ -93,14 +171,14 @@ const columns = (
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={info.row.original.renderablePartCount === 0}
onClick={() => onExportSession(info.row.original)}
onClick={() => onExportSession(withoutChildren(info.row.original))}
>
<Download className="mr-2 size-4" />
Export session
</DropdownMenuItem>
<DropdownMenuItem
className="text-[var(--destructive)]"
onClick={() => onDeleteSession(info.row.original)}
onClick={() => onDeleteSession(withoutChildren(info.row.original))}
>
<Trash2 className="mr-2 size-4" />
Delete session
Expand All @@ -122,14 +200,17 @@ export function ClaudeCodeSessionsTable({
sessions,
}: ClaudeCodeSessionsTableProps) {
const tableColumns = useMemo(() => columns(onDeleteSession, onExportSession), [onDeleteSession, onExportSession]);
const sessionTreeRoots = useMemo(() => getSessionTreeRoots(sessions), [sessions]);

return (
<DataTable
columns={tableColumns}
data={sessions}
data={sessionTreeRoots}
emptyMessage="No Claude Code sessions match the current workspace filter."
enableRowSelection
expandAllRows
getRowId={(row) => row.sessionId}
getSubRows={(row) => row.children}
initialSorting={defaultSorting}
renderToolbar={({ clearSelection, selectedRows }) => {
const selectedSessionIds = selectedRows.map((row) => row.sessionId);
Expand Down
33 changes: 32 additions & 1 deletion apps/ui/src/components/source-tables.vitest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const sessionSpecs: Array<{
},
{
Component: ClaudeCodeSessionsTable as unknown as ComponentType<SessionTableProps>,
expectedValues: ['Claude model', '1,234', '2,500 tokens', '1.0.0'],
expectedValues: ['Claude Model', '1,234', '2,500 tokens', '1.0.0'],
route: '/claude-code-sessions/claude-session',
session: {
lastActiveAtMs: 1_700_000_000_000,
Expand Down Expand Up @@ -261,6 +261,37 @@ describe('source session tables', () => {
expect(onDeleteSession).toHaveBeenCalledWith(session);
});
}

it('should render Claude Code sub-agents as nested rows beneath their parent', () => {
const parent = {
...sessionSpecs[1]!.session,
hierarchy: { parentSessionId: null },
sessionId: 'parent-session',
title: 'Fingerprint Wave 1 behavioral fixes',
};
const child = {
...parent,
hierarchy: { parentSessionId: 'parent-session' },
model: 'claude-opus-5',
sessionId: 'agent-a1d79cbf732582863',
title: 'Implement fingerprint #100 and #101',
};

render(
<ClaudeCodeSessionsTable
sessions={[parent, child] as never}
onDeleteSession={vi.fn()}
onDeleteSessions={vi.fn()}
onExportSession={vi.fn()}
onExportSessions={vi.fn()}
/>,
);

const childLink = screen.getByRole('link', { name: /Implement fingerprint #100 and #101/ });
expect(childLink.closest('[data-row-depth="1"]')).toBeTruthy();
expect(childLink.closest('[data-row-depth="0"]')).toBeNull();
expect(screen.getByText('Claude Opus 5')).toBeTruthy();
});
});

describe('source workspace tables', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/lib/claude-code-server.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const buildTranscript = (entryCount: number, filePath = '/tmp/session-large.json
cwd: '/workspace/project',
filePath,
gitBranch: null,
hierarchy: { parentSessionId: null },
inputTokens: 0,
lastActiveAtIso: '2026-06-01T11:00:00.000Z',
lastActiveAtMs: 2,
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/lib/claude-code-transcript-events.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const transcript: ClaudeCodeSessionTranscript = {
cwd: '/workspace/project',
filePath: '/tmp/session-a.jsonl',
gitBranch: null,
hierarchy: { parentSessionId: null },
inputTokens: 5,
lastActiveAtIso: '2026-06-01T10:00:02.000Z',
lastActiveAtMs: 1_780_307_202_000,
Expand Down
7 changes: 5 additions & 2 deletions apps/ui/src/routes/claude-code-sessions.$sessionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
} from '#/lib/claude-code-transcript-events';
import { downloadTextFile, downloadUrlFile } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber, formatTokens } from '#/lib/formatters';
import { formatDateTime, formatList, formatModelLabel, formatNumber, formatTokens } from '#/lib/formatters';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { applyPathTransforms } from '#/lib/path-utils';
import {
Expand Down Expand Up @@ -90,7 +90,10 @@ const buildSessionMetadata = (detail: ClaudeCodeSessionTranscript) => [
},
{ label: 'Worktree', value: detail.session.worktree },
{ label: 'CWD', value: detail.session.cwd },
{ label: 'Model', value: detail.session.model ?? 'unknown' },
{
label: 'Model',
value: detail.session.model ? formatModelLabel(detail.session.model) : 'unknown',
},
{ label: 'Version', value: detail.session.version ?? 'unknown' },
{ label: 'Git branch', value: detail.session.gitBranch ?? 'unknown' },
{ label: 'Created', value: <span suppressHydrationWarning>{formatDateTime(detail.session.createdAtMs)}</span> },
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@
"coverage:ui": "vitest run --coverage --config apps/ui/vitest.config.ts && bun run ./src/coverage-check.ts ui",
"format": "biome check . --write && biome lint . --write",
"lint": "biome check .",
"prepublishOnly": "bun run build && bun run test:package",
"start": "bun --cwd apps/ui --bun vite dev --host 127.0.0.1 --port 3000",
"test:package": "bun run ./src/package-smoke.ts",
"test:ui": "vitest run --config apps/ui/vitest.config.ts",
"typecheck": "bun run typecheck:root && bun run typecheck:ui",
"typecheck:root": "bunx tsc --noEmit",
Expand Down
29 changes: 29 additions & 0 deletions src/lib/antigravity-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,9 @@ describe('antigravity db discovery', () => {
const deletedDatabaseWalPath = `${deletedDatabasePath}-wal`;
const deletedTranscriptPath = path.join(deletedLogsDir, 'overview.txt');
const deletedFullTranscriptPath = path.join(deletedLogsDir, 'transcript_full.jsonl');
const deletedAnnotationPath = path.join(root, 'annotations', `${deletedId}.pbtxt`);
await mkdir(deletedLogsDir, { recursive: true });
await mkdir(path.dirname(deletedAnnotationPath), { recursive: true });
await Bun.write(
path.join(root, 'agyhub_summaries_proto.pb'),
encodeSummaryIndex([
Expand All @@ -1042,6 +1044,7 @@ describe('antigravity db discovery', () => {
await Bun.write(deletedDatabaseWalPath, new Uint8Array([8]));
await Bun.write(deletedTranscriptPath, '{}\n');
await Bun.write(deletedFullTranscriptPath, '{}\n');
await Bun.write(deletedAnnotationPath, 'last_user_view_time: { seconds: 1700000000 }\n');
await Bun.write(path.join(deletedArtifactDir, 'artifact.md'), 'Generated artifact.\n');
await Bun.write(path.join(root, 'conversations', `${retainedId}.pb`), new Uint8Array([4, 5]));

Expand All @@ -1051,6 +1054,7 @@ describe('antigravity db discovery', () => {
expect(result.deletedPaths.sort()).toEqual(
[
deletedArtifactDir,
deletedAnnotationPath,
deletedConversationPath,
deletedDatabasePath,
deletedDatabaseShmPath,
Expand All @@ -1065,12 +1069,37 @@ describe('antigravity db discovery', () => {
expect(await Bun.file(deletedDatabaseWalPath).exists()).toBe(false);
expect(await Bun.file(deletedTranscriptPath).exists()).toBe(false);
expect(await Bun.file(deletedFullTranscriptPath).exists()).toBe(false);
expect(await Bun.file(deletedAnnotationPath).exists()).toBe(false);
expect(await Bun.file(path.join(deletedArtifactDir, 'artifact.md')).exists()).toBe(false);

const conversations = await listAntigravityConversations([root]);
expect(conversations.map((conversation) => conversation.conversationId)).toEqual([retainedId]);
});

it('should remove a conversation database recreated once by an active Antigravity SQLite connection', async () => {
const root = await makeRoot();
const conversationId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const databasePath = path.join(root, 'conversations', `${conversationId}.db`);
await Bun.write(databasePath, new Uint8Array([1, 2, 3]));
let recreated = false;
const recreateAfterUnlink = (async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (!(await Bun.file(databasePath).exists())) {
await Bun.write(databasePath, new Uint8Array([4, 5, 6]));
recreated = true;
return;
}
await Bun.sleep(1);
}
})();

await deleteAntigravityConversation([root], conversationId);
await recreateAfterUnlink;

expect(recreated).toBe(true);
expect(await Bun.file(databasePath).exists()).toBe(false);
});

it('should replace a read-only Antigravity summary index atomically', async () => {
const root = await makeRoot();
const deletedId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
Expand Down
30 changes: 22 additions & 8 deletions src/lib/antigravity-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,13 +1165,15 @@ const existingAntigravityDeletePaths = async (root: string, conversationId: stri
const conversationDir = getAntigravityConversationDir(root);
const protobufPath = path.join(conversationDir, `${conversationId}.pb`);
const databasePath = path.join(conversationDir, `${conversationId}.db`);
const annotationPath = path.join(root, 'annotations', `${conversationId}.pbtxt`);
const artifactDir = path.join(getAntigravityBrainDir(root), conversationId);
const logsDir = path.join(artifactDir, '.system_generated', 'logs');
const candidates = [
protobufPath,
databasePath,
`${databasePath}-shm`,
`${databasePath}-wal`,
annotationPath,
path.join(logsDir, 'overview.txt'),
path.join(logsDir, 'transcript.jsonl'),
path.join(logsDir, 'transcript_full.jsonl'),
Expand All @@ -1181,6 +1183,18 @@ const existingAntigravityDeletePaths = async (root: string, conversationId: stri
return candidates.filter((_, index) => exists[index]);
};

const removeAntigravityConversationPaths = async (root: string, conversationId: string): Promise<void> => {
const conversationDir = getAntigravityConversationDir(root);
await Promise.all([
rm(path.join(root, 'annotations', `${conversationId}.pbtxt`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.pb`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db-shm`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db-wal`), { force: true }),
]);
await rm(path.join(getAntigravityBrainDir(root), conversationId), { force: true, recursive: true });
};

export const deleteAntigravityConversation = async (
roots: string[],
conversationId: string,
Expand All @@ -1199,15 +1213,15 @@ export const deleteAntigravityConversation = async (

const rootPaths = await existingAntigravityDeletePaths(root, conversationId);
deletedPaths.push(...rootPaths);
await removeAntigravityConversationPaths(root, conversationId);
}

const conversationDir = getAntigravityConversationDir(root);
await Promise.all([
rm(path.join(conversationDir, `${conversationId}.pb`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db-shm`), { force: true }),
rm(path.join(conversationDir, `${conversationId}.db-wal`), { force: true }),
]);
await rm(path.join(getAntigravityBrainDir(root), conversationId), { force: true, recursive: true });
if (deletedSummary || deletedPaths.length > 0) {
await Bun.sleep(10);
for (const root of roots) {
await removeConversationFromSummaryIndex(getAntigravitySummaryIndexPath(root), conversationId);
await removeAntigravityConversationPaths(root, conversationId);
}
Comment on lines +1385 to +1398

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not use a fixed delay as the deletion boundary.

Line 1220 waits 10 ms and then runs one final cleanup pass. A live writer can recreate the database or a SQLite sidecar after that pass. The function then reports deletion while the conversation artifact remains on disk.

Coordinate deletion with the process that owns active SQLite connections. If coordination is not possible, detect and report incomplete cleanup instead of treating a timed second pass as complete. Add a test that recreates the database after the second pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/antigravity-db.ts` around lines 1219 - 1224, Replace the fixed
Bun.sleep(10) retry boundary in the deletion flow surrounding
removeConversationFromSummaryIndex and removeAntigravityConversationPaths with
coordination with the active SQLite connection owner. If coordination cannot
guarantee quiescence, verify all conversation artifacts are absent after cleanup
and report incomplete deletion rather than returning success; add coverage that
recreates the database after the second cleanup pass.

}

return {
Expand Down
Loading
Loading