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.
Files changed (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. 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
- const isCommentaryMessage = (event: ThreadEvent) =>
25
- event.kind === 'message' && event.role === 'assistant' && event.phase === 'commentary';
30
+ export type TranscriptSortOrder = 'earliest' | 'latest';
26
31
 
27
- const shouldShowEvent = (
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
- if (isCommentaryMessage(event) && !showCommentary) {
35
- return false;
36
- }
37
-
38
- if (event.kind === 'message') {
39
- if (event.role === 'user' && !showUserMessages) {
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 buildEventKey = (event: ThreadEvent, index: number) => {
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 visibleEvents = useMemo(
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, buildEventKey(event, index)])),
394
+ () => new Map(events.map((event, index) => [event, getTranscriptEventKey(event, index)])),
345
395
  [events],
346
396
  );
347
- const getEventKey = (event: ThreadEvent) => eventKeyByEvent.get(event) ?? buildEventKey(event, -1);
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) ?? buildEventKey(event, -1)),
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
- <Button
472
- aria-label="Copy selected messages"
473
- className="hover:bg-[var(--panel-secondary)] hover:text-[var(--foreground)]"
474
- disabled={visibleSelectedKeys.length === 0}
475
- size="sm"
476
- variant="outline"
477
- onClick={() => void handleCopySelected()}
478
- >
479
- {copiedSelection ? <Check className="text-[var(--accent)]" /> : <Copy />}
480
- {copiedSelection ? 'Copied' : 'Copy'}
481
- </Button>
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
- <Button
526
- aria-label="Copy selected messages"
527
- className="hover:bg-[var(--panel-secondary)] hover:text-[var(--foreground)]"
528
- disabled={visibleSelectedKeys.length === 0}
529
- size="sm"
530
- variant="outline"
531
- onClick={() => void handleCopySelected()}
532
- >
533
- {copiedSelection ? <Check className="text-[var(--accent)]" /> : <Copy />}
534
- {copiedSelection ? 'Copied' : 'Copy'}
535
- </Button>
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-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
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
- return (
25
- hasReadableAntigravityConversation(conversation) ||
26
- (hasEncryptedAntigravityConversation(conversation) && hasKeychainSecret)
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 = transcriptLocked
61
- ? null
62
- : await renderAntigravityConversationMarkdown(conversation, { keychainSecret });
63
- const artifactsMarkdown =
64
- conversation.artifactCount > 0 ? await renderAntigravityArtifactsMarkdown(conversation) : null;
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 renderAntigravityConversationMarkdown(conversation, { keychainSecret });
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
- filename: `${conversationId}.md`,
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+(.+)$/u;
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
- return body.split(/^###\s+/mu)[0]?.trim() ?? '';
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((line, index) => index > startIndex && line.startsWith('### '));
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
+ });