spiracha 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/apps/ui/README.md +20 -2
- package/apps/ui/package.json +2 -2
- package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +1 -0
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +7 -5
- package/apps/ui/src/components/export-dialog.tsx +2 -12
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/threads-table.tsx +9 -46
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +152 -7
- package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +175 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +19 -3
- package/apps/ui/src/lib/codex-server.ts +135 -53
- package/apps/ui/src/lib/cursor-server.ts +48 -5
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +169 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +85 -7
- package/apps/ui/src/lib/opencode-server.ts +85 -7
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
- package/apps/ui/src/routeTree.gen.ts +107 -0
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
- package/apps/ui/src/routes/opencode.index.tsx +16 -3
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
- package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
- package/package.json +19 -18
- package/src/client.ts +134 -1
- package/src/lib/antigravity-db.ts +208 -32
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/claude-code-db.ts +498 -32
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-browser-export.ts +23 -27
- package/src/lib/codex-thread-cache.ts +41 -13
- package/src/lib/codex-thread-parser.ts +86 -35
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +39 -19
- package/src/lib/concurrency.ts +41 -0
- package/src/lib/conversation-api.ts +399 -19
- package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
- package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
- package/src/lib/conversation-data/codex-adapter.ts +28 -4
- package/src/lib/conversation-data/cursor-adapter.ts +35 -1
- package/src/lib/conversation-data/grok-adapter.ts +210 -0
- package/src/lib/conversation-data/index.ts +76 -1
- package/src/lib/conversation-data/kiro-adapter.ts +41 -7
- package/src/lib/conversation-data/opencode-adapter.ts +24 -2
- package/src/lib/conversation-data/qoder-adapter.ts +44 -19
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +57 -0
- package/src/lib/cursor-db.ts +34 -5
- package/src/lib/grok-db.ts +1026 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +52 -0
- package/src/lib/grok-transcript.ts +154 -0
- package/src/lib/kiro-db.ts +52 -1
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +598 -55
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/shared.ts +12 -0
- package/src/lib/transcript-load-limiter.ts +82 -0
- package/src/lib/ui-cache.ts +43 -17
- package/src/lib/ui-export-archive.ts +60 -0
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { ThreadEvent } from '@spiracha/lib/codex-browser-types';
|
|
2
|
+
import { shouldShowCodexTranscriptEvent } from '@spiracha/lib/codex-transcript-filter';
|
|
2
3
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
4
|
import { Check, Copy } from 'lucide-react';
|
|
4
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
6
|
import { Badge } from '#/components/ui/badge';
|
|
6
7
|
import { Button } from '#/components/ui/button';
|
|
7
8
|
import { Checkbox } from '#/components/ui/checkbox';
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#/components/ui/select';
|
|
8
10
|
import { formatDateTime, formatModelLabel } from '#/lib/formatters';
|
|
9
11
|
import { applyPathTransforms } from '#/lib/path-utils';
|
|
10
12
|
import { useSettings } from '#/lib/settings-store';
|
|
11
13
|
import { cn } from '#/lib/utils';
|
|
12
14
|
|
|
13
15
|
type TranscriptViewProps = {
|
|
16
|
+
activeEventJumpSignal?: number;
|
|
17
|
+
activeEventKey?: string | null;
|
|
14
18
|
assistantModel: string | null;
|
|
15
19
|
events: ThreadEvent[];
|
|
16
20
|
projectPath: string | null;
|
|
@@ -19,36 +23,27 @@ type TranscriptViewProps = {
|
|
|
19
23
|
showRawJson: boolean;
|
|
20
24
|
showToolCalls: boolean;
|
|
21
25
|
showUserMessages?: boolean;
|
|
26
|
+
sortOrder?: TranscriptSortOrder;
|
|
27
|
+
onSortOrderChange?: (value: TranscriptSortOrder) => void;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
event.kind === 'message' && event.role === 'assistant' && event.phase === 'commentary';
|
|
30
|
+
export type TranscriptSortOrder = 'earliest' | 'latest';
|
|
26
31
|
|
|
27
|
-
const
|
|
32
|
+
export const DEFAULT_SHOW_USER_MESSAGES = false;
|
|
33
|
+
|
|
34
|
+
export const shouldShowEvent = (
|
|
28
35
|
event: ThreadEvent,
|
|
29
36
|
showToolCalls: boolean,
|
|
30
37
|
showExtraEvents: boolean,
|
|
31
38
|
showCommentary: boolean,
|
|
32
39
|
showUserMessages: boolean,
|
|
33
|
-
) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return !event.isHiddenByDefault || showExtraEvents;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (event.kind === 'tool_call' || event.kind === 'tool_output') {
|
|
47
|
-
return showToolCalls;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return showExtraEvents;
|
|
51
|
-
};
|
|
40
|
+
) =>
|
|
41
|
+
shouldShowCodexTranscriptEvent(event, {
|
|
42
|
+
showCommentary,
|
|
43
|
+
showExtraEvents,
|
|
44
|
+
showToolCalls,
|
|
45
|
+
showUserMessages,
|
|
46
|
+
});
|
|
52
47
|
|
|
53
48
|
const getEventTone = (event: ThreadEvent) => {
|
|
54
49
|
if (event.kind === 'message' && event.role === 'assistant') {
|
|
@@ -153,7 +148,7 @@ const getEventMarkdownBody = (event: ThreadEvent, transform: Transform) => {
|
|
|
153
148
|
const getEventMarkdown = (event: ThreadEvent, assistantModel: string | null, transform: Transform) =>
|
|
154
149
|
`## ${getEventTitle(event, assistantModel)}\n\n${getEventMarkdownBody(event, transform)}`;
|
|
155
150
|
|
|
156
|
-
const
|
|
151
|
+
export const getTranscriptEventKey = (event: ThreadEvent, index: number) => {
|
|
157
152
|
if (event.kind === 'tool_call') {
|
|
158
153
|
return `${event.kind}-${event.sequence}-${event.callId ?? event.timestamp ?? event.name}-${index}`;
|
|
159
154
|
}
|
|
@@ -248,36 +243,81 @@ type TranscriptEventCardProps = {
|
|
|
248
243
|
assistantModel: string | null;
|
|
249
244
|
copied: boolean;
|
|
250
245
|
event: ThreadEvent;
|
|
246
|
+
eventKey: string;
|
|
247
|
+
isActive: boolean;
|
|
251
248
|
isSelected: boolean;
|
|
252
249
|
showRawJson: boolean;
|
|
253
250
|
transform: Transform;
|
|
251
|
+
onCardElement: (eventKey: string, element: HTMLElement | null) => void;
|
|
254
252
|
onCopy: (event: ThreadEvent) => void;
|
|
255
253
|
onSelectionChange: (event: ThreadEvent, checked: boolean) => void;
|
|
256
254
|
};
|
|
257
255
|
|
|
256
|
+
function TranscriptSortSelect({
|
|
257
|
+
sortOrder,
|
|
258
|
+
onSortOrderChange,
|
|
259
|
+
}: {
|
|
260
|
+
sortOrder: TranscriptSortOrder;
|
|
261
|
+
onSortOrderChange?: (value: TranscriptSortOrder) => void;
|
|
262
|
+
}) {
|
|
263
|
+
return (
|
|
264
|
+
<div className="flex items-center gap-2 text-sm">
|
|
265
|
+
<label className="text-[var(--muted-foreground)]" htmlFor="transcript-sort-order">
|
|
266
|
+
Sort by
|
|
267
|
+
</label>
|
|
268
|
+
<Select value={sortOrder} onValueChange={(value) => onSortOrderChange?.(value as TranscriptSortOrder)}>
|
|
269
|
+
<SelectTrigger
|
|
270
|
+
aria-label="Sort transcript messages"
|
|
271
|
+
className="h-8 w-[10.5rem] rounded-full border-[var(--border)] bg-[var(--panel)] text-xs"
|
|
272
|
+
id="transcript-sort-order"
|
|
273
|
+
>
|
|
274
|
+
<SelectValue />
|
|
275
|
+
</SelectTrigger>
|
|
276
|
+
<SelectContent className="border-[var(--border)] bg-[var(--panel)] text-[var(--foreground)]">
|
|
277
|
+
<SelectItem value="earliest">Earliest first</SelectItem>
|
|
278
|
+
<SelectItem value="latest">Latest first</SelectItem>
|
|
279
|
+
</SelectContent>
|
|
280
|
+
</Select>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
258
285
|
function TranscriptEventCard({
|
|
259
286
|
assistantModel,
|
|
260
287
|
copied,
|
|
261
288
|
event,
|
|
289
|
+
eventKey,
|
|
290
|
+
isActive,
|
|
262
291
|
isSelected,
|
|
263
292
|
showRawJson,
|
|
264
293
|
transform,
|
|
294
|
+
onCardElement,
|
|
265
295
|
onCopy,
|
|
266
296
|
onSelectionChange,
|
|
267
297
|
}: TranscriptEventCardProps) {
|
|
298
|
+
const handleElement = useCallback(
|
|
299
|
+
(element: HTMLElement | null) => onCardElement(eventKey, element),
|
|
300
|
+
[eventKey, onCardElement],
|
|
301
|
+
);
|
|
302
|
+
|
|
268
303
|
return (
|
|
269
304
|
<article
|
|
305
|
+
ref={handleElement}
|
|
306
|
+
aria-current={isActive ? 'location' : undefined}
|
|
270
307
|
className={cn(
|
|
271
|
-
'min-w-0 overflow-hidden rounded-xl border p-3.5 shadow-[var(--panel-shadow)]',
|
|
308
|
+
'min-w-0 scroll-mt-24 overflow-hidden rounded-xl border p-3.5 shadow-[var(--panel-shadow)]',
|
|
272
309
|
isSelected && 'ring-2 ring-[var(--accent)]/35',
|
|
310
|
+
isActive && 'ring-2 ring-[var(--accent)]',
|
|
273
311
|
getEventTone(event),
|
|
274
312
|
)}
|
|
313
|
+
data-transcript-event-key={eventKey}
|
|
275
314
|
>
|
|
276
315
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
277
316
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
278
317
|
<Checkbox
|
|
279
318
|
aria-label={`Select ${getEventTitle(event, assistantModel)}`}
|
|
280
319
|
checked={isSelected}
|
|
320
|
+
className="size-5 cursor-pointer"
|
|
281
321
|
onCheckedChange={(checked) => onSelectionChange(event, checked === true)}
|
|
282
322
|
/>
|
|
283
323
|
<h3 className="min-w-0 break-words font-semibold text-sm [overflow-wrap:anywhere]">
|
|
@@ -316,6 +356,8 @@ function TranscriptEventCard({
|
|
|
316
356
|
}
|
|
317
357
|
|
|
318
358
|
export function TranscriptView({
|
|
359
|
+
activeEventJumpSignal = 0,
|
|
360
|
+
activeEventKey = null,
|
|
319
361
|
assistantModel,
|
|
320
362
|
events,
|
|
321
363
|
projectPath,
|
|
@@ -324,33 +366,45 @@ export function TranscriptView({
|
|
|
324
366
|
showRawJson,
|
|
325
367
|
showToolCalls,
|
|
326
368
|
showUserMessages = true,
|
|
369
|
+
sortOrder = 'earliest',
|
|
370
|
+
onSortOrderChange,
|
|
327
371
|
}: TranscriptViewProps) {
|
|
328
372
|
const { settings } = useSettings();
|
|
329
|
-
const
|
|
373
|
+
const filteredEvents = useMemo(
|
|
330
374
|
() =>
|
|
331
375
|
events.filter((event) =>
|
|
332
376
|
shouldShowEvent(event, showToolCalls, showExtraEvents, showCommentary, showUserMessages),
|
|
333
377
|
),
|
|
334
378
|
[events, showCommentary, showExtraEvents, showToolCalls, showUserMessages],
|
|
335
379
|
);
|
|
380
|
+
const visibleEvents = useMemo(
|
|
381
|
+
() => (sortOrder === 'latest' ? [...filteredEvents].reverse() : filteredEvents),
|
|
382
|
+
[filteredEvents, sortOrder],
|
|
383
|
+
);
|
|
336
384
|
const [copiedEventKeys, setCopiedEventKeys] = useState<string[]>([]);
|
|
337
385
|
const [copiedSelection, setCopiedSelection] = useState(false);
|
|
338
386
|
const [copyErrorMessage, setCopyErrorMessage] = useState<string | null>(null);
|
|
339
387
|
const [selectedEventKeys, setSelectedEventKeys] = useState<string[]>([]);
|
|
388
|
+
const eventElementByKeyRef = useRef(new Map<string, HTMLElement>());
|
|
389
|
+
const lastHandledJumpRef = useRef<{ eventKey: string; signal: number } | null>(null);
|
|
340
390
|
const parentRef = useRef<HTMLDivElement | null>(null);
|
|
341
391
|
const timeoutIdsRef = useRef<number[]>([]);
|
|
342
392
|
const useVirtualList = visibleEvents.length > 40;
|
|
343
393
|
const eventKeyByEvent = useMemo(
|
|
344
|
-
() => new Map(events.map((event, index) => [event,
|
|
394
|
+
() => new Map(events.map((event, index) => [event, getTranscriptEventKey(event, index)])),
|
|
345
395
|
[events],
|
|
346
396
|
);
|
|
347
|
-
const getEventKey = (event: ThreadEvent) => eventKeyByEvent.get(event) ??
|
|
397
|
+
const getEventKey = (event: ThreadEvent) => eventKeyByEvent.get(event) ?? getTranscriptEventKey(event, -1);
|
|
348
398
|
const transform = (text: string) => applyPathTransforms(text, settings, projectPath);
|
|
349
399
|
const visibleEventKeys = useMemo(
|
|
350
|
-
() => visibleEvents.map((event) => eventKeyByEvent.get(event) ??
|
|
400
|
+
() => visibleEvents.map((event) => eventKeyByEvent.get(event) ?? getTranscriptEventKey(event, -1)),
|
|
351
401
|
[visibleEvents, eventKeyByEvent],
|
|
352
402
|
);
|
|
353
403
|
const visibleEventKeySet = useMemo(() => new Set(visibleEventKeys), [visibleEventKeys]);
|
|
404
|
+
const activeVisibleEventIndex = useMemo(
|
|
405
|
+
() => (activeEventKey ? visibleEventKeys.indexOf(activeEventKey) : -1),
|
|
406
|
+
[activeEventKey, visibleEventKeys],
|
|
407
|
+
);
|
|
354
408
|
const selectedEventKeySet = useMemo(() => new Set(selectedEventKeys), [selectedEventKeys]);
|
|
355
409
|
const virtualizer = useVirtualizer({
|
|
356
410
|
count: useVirtualList ? visibleEvents.length : 0,
|
|
@@ -361,6 +415,15 @@ export function TranscriptView({
|
|
|
361
415
|
overscan: 8,
|
|
362
416
|
});
|
|
363
417
|
|
|
418
|
+
const handleCardElement = useCallback((eventKey: string, element: HTMLElement | null) => {
|
|
419
|
+
if (element) {
|
|
420
|
+
eventElementByKeyRef.current.set(eventKey, element);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
eventElementByKeyRef.current.delete(eventKey);
|
|
425
|
+
}, []);
|
|
426
|
+
|
|
364
427
|
useEffect(() => {
|
|
365
428
|
return () => {
|
|
366
429
|
for (const timeoutId of timeoutIdsRef.current) {
|
|
@@ -377,6 +440,27 @@ export function TranscriptView({
|
|
|
377
440
|
});
|
|
378
441
|
}, [visibleEventKeySet]);
|
|
379
442
|
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
if (!Number.isFinite(activeEventJumpSignal) || !activeEventKey || activeVisibleEventIndex < 0) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (
|
|
449
|
+
lastHandledJumpRef.current?.signal === activeEventJumpSignal &&
|
|
450
|
+
lastHandledJumpRef.current.eventKey === activeEventKey
|
|
451
|
+
) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
lastHandledJumpRef.current = { eventKey: activeEventKey, signal: activeEventJumpSignal };
|
|
455
|
+
|
|
456
|
+
if (useVirtualList) {
|
|
457
|
+
virtualizer.scrollToIndex(activeVisibleEventIndex, { align: 'start' });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
eventElementByKeyRef.current.get(activeEventKey)?.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
462
|
+
}, [activeEventJumpSignal, activeEventKey, activeVisibleEventIndex, useVirtualList, virtualizer]);
|
|
463
|
+
|
|
380
464
|
const scheduleTimeout = (callback: () => void, delayMs: number) => {
|
|
381
465
|
const timeoutId = window.setTimeout(() => {
|
|
382
466
|
timeoutIdsRef.current = timeoutIdsRef.current.filter((entry) => entry !== timeoutId);
|
|
@@ -468,17 +552,20 @@ export function TranscriptView({
|
|
|
468
552
|
<span className="text-[var(--destructive)] text-sm">{copyErrorMessage}</span>
|
|
469
553
|
) : null}
|
|
470
554
|
</div>
|
|
471
|
-
<
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
555
|
+
<div className="flex items-center gap-2">
|
|
556
|
+
<TranscriptSortSelect sortOrder={sortOrder} onSortOrderChange={onSortOrderChange} />
|
|
557
|
+
<Button
|
|
558
|
+
aria-label="Copy selected messages"
|
|
559
|
+
className="hover:bg-[var(--panel-secondary)] hover:text-[var(--foreground)]"
|
|
560
|
+
disabled={visibleSelectedKeys.length === 0}
|
|
561
|
+
size="sm"
|
|
562
|
+
variant="outline"
|
|
563
|
+
onClick={() => void handleCopySelected()}
|
|
564
|
+
>
|
|
565
|
+
{copiedSelection ? <Check className="text-[var(--accent)]" /> : <Copy />}
|
|
566
|
+
{copiedSelection ? 'Copied' : 'Copy'}
|
|
567
|
+
</Button>
|
|
568
|
+
</div>
|
|
482
569
|
</div>
|
|
483
570
|
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
|
484
571
|
{virtualizer.getVirtualItems().map((item) => (
|
|
@@ -493,9 +580,12 @@ export function TranscriptView({
|
|
|
493
580
|
assistantModel={assistantModel}
|
|
494
581
|
copied={copiedEventKeys.includes(getEventKey(visibleEvents[item.index]!))}
|
|
495
582
|
event={visibleEvents[item.index]!}
|
|
583
|
+
eventKey={getEventKey(visibleEvents[item.index]!)}
|
|
584
|
+
isActive={activeEventKey === getEventKey(visibleEvents[item.index]!)}
|
|
496
585
|
isSelected={selectedEventKeySet.has(getEventKey(visibleEvents[item.index]!))}
|
|
497
586
|
showRawJson={showRawJson}
|
|
498
587
|
transform={transform}
|
|
588
|
+
onCardElement={handleCardElement}
|
|
499
589
|
onCopy={(event) => void handleCopyEvent(event)}
|
|
500
590
|
onSelectionChange={handleSelectionChange}
|
|
501
591
|
/>
|
|
@@ -522,27 +612,33 @@ export function TranscriptView({
|
|
|
522
612
|
<span className="text-[var(--destructive)] text-sm">{copyErrorMessage}</span>
|
|
523
613
|
) : null}
|
|
524
614
|
</div>
|
|
525
|
-
<
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
615
|
+
<div className="flex items-center gap-2">
|
|
616
|
+
<TranscriptSortSelect sortOrder={sortOrder} onSortOrderChange={onSortOrderChange} />
|
|
617
|
+
<Button
|
|
618
|
+
aria-label="Copy selected messages"
|
|
619
|
+
className="hover:bg-[var(--panel-secondary)] hover:text-[var(--foreground)]"
|
|
620
|
+
disabled={visibleSelectedKeys.length === 0}
|
|
621
|
+
size="sm"
|
|
622
|
+
variant="outline"
|
|
623
|
+
onClick={() => void handleCopySelected()}
|
|
624
|
+
>
|
|
625
|
+
{copiedSelection ? <Check className="text-[var(--accent)]" /> : <Copy />}
|
|
626
|
+
{copiedSelection ? 'Copied' : 'Copy'}
|
|
627
|
+
</Button>
|
|
628
|
+
</div>
|
|
536
629
|
</div>
|
|
537
630
|
{visibleEvents.map((event) => (
|
|
538
631
|
<TranscriptEventCard
|
|
539
632
|
assistantModel={assistantModel}
|
|
540
633
|
copied={copiedEventKeys.includes(getEventKey(event))}
|
|
541
|
-
key={getEventKey(event)}
|
|
542
634
|
event={event}
|
|
635
|
+
eventKey={getEventKey(event)}
|
|
636
|
+
key={getEventKey(event)}
|
|
637
|
+
isActive={activeEventKey === getEventKey(event)}
|
|
543
638
|
isSelected={selectedEventKeySet.has(getEventKey(event))}
|
|
544
639
|
showRawJson={showRawJson}
|
|
545
640
|
transform={transform}
|
|
641
|
+
onCardElement={handleCardElement}
|
|
546
642
|
onCopy={(selectedEvent) => void handleCopyEvent(selectedEvent)}
|
|
547
643
|
onSelectionChange={handleSelectionChange}
|
|
548
644
|
/>
|
|
@@ -94,7 +94,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
|
|
94
94
|
<SelectPrimitive.Item
|
|
95
95
|
data-slot="select-item"
|
|
96
96
|
className={cn(
|
|
97
|
-
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-
|
|
97
|
+
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-[var(--accent-muted)] focus:text-[var(--foreground)] data-[disabled]:pointer-events-none data-[highlighted]:bg-[var(--accent-muted)] data-[highlighted]:text-[var(--foreground)] data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-[var(--muted-foreground)] [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
98
98
|
className,
|
|
99
99
|
)}
|
|
100
100
|
{...props}
|
|
@@ -10,6 +10,14 @@ export const hasEncryptedAntigravityConversation = (conversation: AntigravityCon
|
|
|
10
10
|
return conversation.transcriptSource === 'safe-storage';
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export const hasSummaryAntigravityConversation = (conversation: AntigravityConversation): boolean => {
|
|
14
|
+
return (
|
|
15
|
+
conversation.artifactCount === 0 &&
|
|
16
|
+
conversation.transcriptSource === null &&
|
|
17
|
+
(conversation.summaryPath !== null || conversation.indexedItemCount !== null)
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
13
21
|
export const isAntigravityConversationLocked = (
|
|
14
22
|
conversation: AntigravityConversation,
|
|
15
23
|
hasKeychainSecret: boolean,
|
|
@@ -21,8 +29,13 @@ export const canExportAntigravityConversation = (
|
|
|
21
29
|
conversation: AntigravityConversation,
|
|
22
30
|
hasKeychainSecret: boolean,
|
|
23
31
|
): boolean => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if (hasReadableAntigravityConversation(conversation)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hasEncryptedAntigravityConversation(conversation)) {
|
|
37
|
+
return hasKeychainSecret;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return hasSummaryAntigravityConversation(conversation);
|
|
28
41
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildConversationExportBaseName } from '@spiracha/lib/ui-export-archive';
|
|
1
2
|
import { createServerFn } from '@tanstack/react-start';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { canExportAntigravityConversation, isAntigravityConversationLocked } from './antigravity-conversation-state';
|
|
@@ -14,6 +15,16 @@ const exportSchema = z.object({
|
|
|
14
15
|
conversationId: z.string().min(1),
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
const exportConversationsSchema = z.object({
|
|
19
|
+
conversationIds: z.array(z.string().min(1)).min(1),
|
|
20
|
+
outputFormat: z.enum(['md', 'txt']).default('md'),
|
|
21
|
+
zipArchive: z.boolean().default(true),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const deleteConversationsSchema = z.object({
|
|
25
|
+
conversationIds: z.array(z.string().min(1)).min(1),
|
|
26
|
+
});
|
|
27
|
+
|
|
17
28
|
export const listAntigravityWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
18
29
|
const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
|
|
19
30
|
return listAntigravityWorkspaceGroups();
|
|
@@ -48,7 +59,24 @@ const findAntigravityConversationById = async (conversationId: string) => {
|
|
|
48
59
|
return conversation;
|
|
49
60
|
};
|
|
50
61
|
|
|
62
|
+
const resolveAntigravityConversationGroup = async (
|
|
63
|
+
conversation: Awaited<ReturnType<typeof findAntigravityConversationById>>,
|
|
64
|
+
) => {
|
|
65
|
+
const fallback = {
|
|
66
|
+
key: conversation.projectId ? `project:${conversation.projectId}` : conversation.workspaceKey,
|
|
67
|
+
label: conversation.workspaceLabel,
|
|
68
|
+
};
|
|
69
|
+
if (!conversation.projectId) {
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
|
|
74
|
+
const group = (await listAntigravityWorkspaceGroups()).find((candidate) => candidate.key === fallback.key);
|
|
75
|
+
return group ? { key: group.key, label: group.label } : fallback;
|
|
76
|
+
};
|
|
77
|
+
|
|
51
78
|
export const loadAntigravityConversationDetail = async (conversationId: string) => {
|
|
79
|
+
const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
|
|
52
80
|
const { renderAntigravityArtifactsMarkdown, renderAntigravityConversationMarkdown } = await import(
|
|
53
81
|
'@spiracha/lib/antigravity-db'
|
|
54
82
|
);
|
|
@@ -57,15 +85,26 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
|
|
|
57
85
|
const keychainSecret = getCachedAntigravityKeychainSecret();
|
|
58
86
|
const hasKeychainSecret = Boolean(keychainSecret);
|
|
59
87
|
const transcriptLocked = isAntigravityConversationLocked(conversation, hasKeychainSecret);
|
|
60
|
-
const conversationMarkdown =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
const [[conversationMarkdown, artifactsMarkdown], conversationGroup] = await Promise.all([
|
|
89
|
+
runWithTranscriptLoadLimit(
|
|
90
|
+
async () =>
|
|
91
|
+
Promise.all([
|
|
92
|
+
transcriptLocked ? null : renderAntigravityConversationMarkdown(conversation, { keychainSecret }),
|
|
93
|
+
conversation.artifactCount > 0 ? renderAntigravityArtifactsMarkdown(conversation) : null,
|
|
94
|
+
]),
|
|
95
|
+
{
|
|
96
|
+
id: conversation.conversationId,
|
|
97
|
+
path: conversation.transcriptPath ?? conversation.conversationPath ?? undefined,
|
|
98
|
+
source: 'antigravity-ui-detail',
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
resolveAntigravityConversationGroup(conversation),
|
|
102
|
+
]);
|
|
65
103
|
|
|
66
104
|
return {
|
|
67
105
|
artifactsMarkdown,
|
|
68
106
|
conversation,
|
|
107
|
+
conversationGroup,
|
|
69
108
|
// Suppress the duplicate panel when artifactsMarkdown and conversationMarkdown are identical.
|
|
70
109
|
conversationMarkdown: conversationMarkdown === artifactsMarkdown ? null : conversationMarkdown,
|
|
71
110
|
transcriptLocked,
|
|
@@ -73,6 +112,7 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
|
|
|
73
112
|
};
|
|
74
113
|
|
|
75
114
|
export const loadAntigravityConversationExport = async (conversationId: string) => {
|
|
115
|
+
const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
|
|
76
116
|
const { renderAntigravityConversationMarkdown } = await import('@spiracha/lib/antigravity-db');
|
|
77
117
|
const { getCachedAntigravityKeychainSecret } = await import('@spiracha/lib/antigravity-keychain');
|
|
78
118
|
const conversation = await findAntigravityConversationById(conversationId);
|
|
@@ -87,14 +127,61 @@ export const loadAntigravityConversationExport = async (conversationId: string)
|
|
|
87
127
|
throw new Error(`No exportable Antigravity transcript found for conversation: ${conversationId}`);
|
|
88
128
|
}
|
|
89
129
|
|
|
90
|
-
const content = await
|
|
130
|
+
const content = await runWithTranscriptLoadLimit(
|
|
131
|
+
() =>
|
|
132
|
+
renderAntigravityConversationMarkdown(conversation, {
|
|
133
|
+
keychainSecret,
|
|
134
|
+
}),
|
|
135
|
+
{
|
|
136
|
+
id: conversation.conversationId,
|
|
137
|
+
path: conversation.transcriptPath ?? conversation.conversationPath ?? undefined,
|
|
138
|
+
source: 'antigravity-ui-export',
|
|
139
|
+
},
|
|
140
|
+
);
|
|
91
141
|
if (!content) {
|
|
92
142
|
throw new Error(`No exportable Antigravity transcript found for conversation: ${conversationId}`);
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
return {
|
|
96
146
|
content,
|
|
97
|
-
|
|
147
|
+
conversation,
|
|
148
|
+
filename: `${buildConversationExportBaseName(
|
|
149
|
+
{
|
|
150
|
+
cwd: conversation.workspaceFolder,
|
|
151
|
+
id: conversation.conversationId,
|
|
152
|
+
updatedAtMs: conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs,
|
|
153
|
+
},
|
|
154
|
+
'antigravity-conversation',
|
|
155
|
+
)}.md`,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const deleteAntigravityConversationById = async (conversationId: string) => {
|
|
160
|
+
const { deleteAntigravityConversation } = await import('@spiracha/lib/antigravity-db');
|
|
161
|
+
const { resolveAntigravityRoots } = await import('@spiracha/lib/antigravity-exporter-types');
|
|
162
|
+
const result = await deleteAntigravityConversation(resolveAntigravityRoots(), conversationId);
|
|
163
|
+
if (result.deletedConversationIds.length === 0) {
|
|
164
|
+
throw new Error(`Antigravity conversation not found: ${conversationId}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return result;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const deleteAntigravityConversationsById = async (conversationIds: string[]) => {
|
|
171
|
+
const { deleteAntigravityConversation } = await import('@spiracha/lib/antigravity-db');
|
|
172
|
+
const { resolveAntigravityRoots } = await import('@spiracha/lib/antigravity-exporter-types');
|
|
173
|
+
const roots = resolveAntigravityRoots();
|
|
174
|
+
const results = await Promise.all(
|
|
175
|
+
conversationIds.map((conversationId) => deleteAntigravityConversation(roots, conversationId)),
|
|
176
|
+
);
|
|
177
|
+
const deletedConversationIds = results.flatMap((result) => result.deletedConversationIds);
|
|
178
|
+
if (deletedConversationIds.length === 0) {
|
|
179
|
+
throw new Error('No Antigravity conversations were deleted');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
deletedConversationIds,
|
|
184
|
+
deletedPaths: results.flatMap((result) => result.deletedPaths),
|
|
98
185
|
};
|
|
99
186
|
};
|
|
100
187
|
|
|
@@ -126,3 +213,61 @@ export const exportAntigravityConversationFn = createServerFn({ method: 'POST' }
|
|
|
126
213
|
.handler(async ({ data }) => {
|
|
127
214
|
return loadAntigravityConversationExport(data.conversationId);
|
|
128
215
|
});
|
|
216
|
+
|
|
217
|
+
export const exportAntigravityConversations = async (data: z.infer<typeof exportConversationsSchema>) => {
|
|
218
|
+
const { renderSourceSessionsDownload } = await import('./source-session-export-server');
|
|
219
|
+
const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
|
|
220
|
+
const groups = await listAntigravityWorkspaceGroups();
|
|
221
|
+
const groupsByKey = new Map(groups.map((group) => [group.key, group]));
|
|
222
|
+
const entries = await Promise.all(
|
|
223
|
+
data.conversationIds.map(async (conversationId) => {
|
|
224
|
+
const result = await loadAntigravityConversationExport(conversationId);
|
|
225
|
+
const conversation = result.conversation;
|
|
226
|
+
const projectGroup = conversation.projectId
|
|
227
|
+
? groupsByKey.get(`project:${conversation.projectId}`)
|
|
228
|
+
: undefined;
|
|
229
|
+
const exportCwd = projectGroup?.label ?? conversation.workspaceFolder;
|
|
230
|
+
const updatedAtMs = conversation.lastUpdatedAtMs ?? conversation.conversationMtimeMs;
|
|
231
|
+
return {
|
|
232
|
+
content: result.content,
|
|
233
|
+
cwd: exportCwd,
|
|
234
|
+
fallbackBaseName: 'antigravity-conversation',
|
|
235
|
+
fileBaseName: buildConversationExportBaseName(
|
|
236
|
+
{
|
|
237
|
+
cwd: exportCwd,
|
|
238
|
+
id: conversation.conversationId,
|
|
239
|
+
updatedAtMs,
|
|
240
|
+
},
|
|
241
|
+
'antigravity-conversation',
|
|
242
|
+
),
|
|
243
|
+
sessionId: conversation.conversationId,
|
|
244
|
+
updatedAtMs,
|
|
245
|
+
};
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return renderSourceSessionsDownload({
|
|
250
|
+
entries,
|
|
251
|
+
fallbackBaseName: 'antigravity-conversations',
|
|
252
|
+
outputFormat: data.outputFormat,
|
|
253
|
+
zipArchive: data.zipArchive,
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const exportAntigravityConversationsFn = createServerFn({ method: 'POST' })
|
|
258
|
+
.validator(exportConversationsSchema)
|
|
259
|
+
.handler(async ({ data }) => {
|
|
260
|
+
return exportAntigravityConversations(data);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
export const deleteAntigravityConversationFn = createServerFn({ method: 'POST' })
|
|
264
|
+
.validator(conversationSchema)
|
|
265
|
+
.handler(async ({ data }) => {
|
|
266
|
+
return deleteAntigravityConversationById(data.conversationId);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
export const deleteAntigravityConversationsFn = createServerFn({ method: 'POST' })
|
|
270
|
+
.validator(deleteConversationsSchema)
|
|
271
|
+
.handler(async ({ data }) => {
|
|
272
|
+
return deleteAntigravityConversationsById(data.conversationIds);
|
|
273
|
+
});
|
|
@@ -18,9 +18,10 @@ type ParsedToolCall = {
|
|
|
18
18
|
name: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
const HEADING_PATTERN = /^##\s+(
|
|
21
|
+
const HEADING_PATTERN = /^##\s+((?:User|Assistant|System|Event)|Tool:\s*.+)$/iu;
|
|
22
22
|
const TIMESTAMP_PATTERN = /^_Timestamp:\s*(.+?)_$/u;
|
|
23
23
|
const TOOL_HEADING_PATTERN = /^tool:\s*(.+)$/iu;
|
|
24
|
+
const CONTROL_SUBHEADING_PATTERN = /^###\s+(Thinking|Tool Calls)\s*$/iu;
|
|
24
25
|
|
|
25
26
|
const splitMarkdownSections = (markdown: string | null): MarkdownSection[] => {
|
|
26
27
|
if (!markdown?.trim()) {
|
|
@@ -72,7 +73,12 @@ const extractTimestamp = (body: string): { body: string; timestamp: string | nul
|
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
const sectionBodyBeforeSubheading = (body: string): string => {
|
|
75
|
-
|
|
76
|
+
const lines = body.split(/\r?\n/u);
|
|
77
|
+
const controlIndex = lines.findIndex((line) => CONTROL_SUBHEADING_PATTERN.test(line.trim()));
|
|
78
|
+
return lines
|
|
79
|
+
.slice(0, controlIndex === -1 ? undefined : controlIndex)
|
|
80
|
+
.join('\n')
|
|
81
|
+
.trim();
|
|
76
82
|
};
|
|
77
83
|
|
|
78
84
|
const extractSubheadingBlock = (body: string, title: string): string => {
|
|
@@ -82,7 +88,9 @@ const extractSubheadingBlock = (body: string, title: string): string => {
|
|
|
82
88
|
return '';
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
const nextSubheadingIndex = lines.findIndex(
|
|
91
|
+
const nextSubheadingIndex = lines.findIndex(
|
|
92
|
+
(line, index) => index > startIndex && CONTROL_SUBHEADING_PATTERN.test(line.trim()),
|
|
93
|
+
);
|
|
86
94
|
return lines
|
|
87
95
|
.slice(startIndex + 1, nextSubheadingIndex === -1 ? undefined : nextSubheadingIndex)
|
|
88
96
|
.join('\n')
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { queryOptions } from '@tanstack/react-query';
|
|
2
2
|
import {
|
|
3
3
|
getClaudeCodeSessionDetailFn,
|
|
4
|
+
getClaudeCodeSessionTranscriptFn,
|
|
4
5
|
listClaudeCodeSessionsFn,
|
|
5
6
|
listClaudeCodeWorkspacesFn,
|
|
6
7
|
} from './claude-code-server';
|
|
@@ -24,3 +25,10 @@ export const claudeCodeSessionDetailQueryOptions = (sessionId: string | null) =>
|
|
|
24
25
|
queryFn: () => getClaudeCodeSessionDetailFn({ data: { sessionId: sessionId ?? '' } }),
|
|
25
26
|
queryKey: ['claude-code-session', sessionId ?? 'none'],
|
|
26
27
|
});
|
|
28
|
+
|
|
29
|
+
export const claudeCodeSessionTranscriptQueryOptions = (sessionId: string | null) =>
|
|
30
|
+
queryOptions({
|
|
31
|
+
enabled: sessionId !== null,
|
|
32
|
+
queryFn: () => getClaudeCodeSessionTranscriptFn({ data: { sessionId: sessionId ?? '' } }),
|
|
33
|
+
queryKey: ['claude-code-session-transcript', sessionId ?? 'none'],
|
|
34
|
+
});
|