spiracha 2.0.0 → 2.2.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 (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -1,23 +1,18 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import {
6
- getExportMimeType,
7
- resolveUniqueExportFileBaseName,
8
- sanitizeExportFileName,
9
- zipExportDirectory,
10
- } from '@spiracha/lib/ui-export-archive';
11
- import { buildUiExportDownloadUrl, ensureUiExportDir } from '@spiracha/lib/ui-export-files';
1
+ import { isSafeCursorComposerId } from '@spiracha/lib/cursor-id';
12
2
  import { createServerFn } from '@tanstack/react-start';
13
3
  import { z } from 'zod';
4
+ import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
14
5
 
15
6
  const workspaceSchema = z.object({
16
7
  workspaceKey: z.string().min(1),
17
8
  });
18
9
 
10
+ const composerIdSchema = z.string().refine(isSafeCursorComposerId, {
11
+ message: 'Invalid Cursor composer id.',
12
+ });
13
+
19
14
  const threadSchema = z.object({
20
- composerId: z.string().min(1),
15
+ composerId: composerIdSchema,
21
16
  });
22
17
 
23
18
  const recoverSchema = z.object({
@@ -26,7 +21,7 @@ const recoverSchema = z.object({
26
21
  });
27
22
 
28
23
  const exportSchema = z.object({
29
- composerId: z.string().min(1),
24
+ composerId: composerIdSchema,
30
25
  includeCommentary: z.boolean().default(true),
31
26
  includeMetadata: z.boolean().default(true),
32
27
  includeTools: z.boolean().default(true),
@@ -35,7 +30,7 @@ const exportSchema = z.object({
35
30
  });
36
31
 
37
32
  const exportThreadsSchema = z.object({
38
- composerIds: z.array(z.string().min(1)).min(1),
33
+ composerIds: z.array(composerIdSchema).min(1),
39
34
  includeCommentary: z.boolean().default(true),
40
35
  includeMetadata: z.boolean().default(true),
41
36
  includeTools: z.boolean().default(true),
@@ -44,7 +39,7 @@ const exportThreadsSchema = z.object({
44
39
  });
45
40
 
46
41
  const deleteThreadsSchema = z.object({
47
- composerIds: z.array(z.string().min(1)).min(1),
42
+ composerIds: z.array(composerIdSchema).min(1),
48
43
  });
49
44
 
50
45
  const ensureCursorClosedForWrite = async () => {
@@ -67,39 +62,29 @@ const findGroupByKey = async (workspaceKey: string) => {
67
62
  return group;
68
63
  };
69
64
 
70
- const toSafeExportName = (value: string) => {
71
- return sanitizeExportFileName(value) || 'cursor-thread';
72
- };
73
-
74
65
  const renderCursorZipDownload = async (
75
- rendered: Array<{ composerId: string; content: string }>,
66
+ rendered: Array<{
67
+ composerId: string;
68
+ content: string;
69
+ cwd: string | null;
70
+ fileBaseName: string;
71
+ updatedAtMs: number | null;
72
+ }>,
76
73
  outputFormat: 'md' | 'txt',
77
74
  ) => {
78
- const exportDir = await ensureUiExportDir();
79
- const exportBaseName =
80
- rendered.length === 1 ? `${toSafeExportName(rendered[0]!.composerId)}` : `cursor-threads-${rendered.length}`;
81
- const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${exportBaseName}-`));
82
- const zipPath = path.join(exportDir, `${exportBaseName}-${randomUUID()}.zip`);
83
- const usedBaseNames = new Map<string, number>();
84
-
85
- try {
86
- for (const entry of rendered) {
87
- const baseName = toSafeExportName(entry.composerId);
88
- const fileBaseName = resolveUniqueExportFileBaseName(baseName, usedBaseNames);
89
- await Bun.write(path.join(workspaceDir, `${fileBaseName}.${outputFormat}`), entry.content);
90
- }
91
-
92
- await zipExportDirectory(workspaceDir, zipPath);
93
- } finally {
94
- await rm(workspaceDir, { force: true, recursive: true });
95
- }
96
-
97
- return {
98
- downloadUrl: buildUiExportDownloadUrl(zipPath),
99
- fileName: `${exportBaseName}.zip`,
100
- mimeType: 'application/zip',
101
- mode: 'download_url' as const,
102
- };
75
+ return renderSourceSessionsDownload({
76
+ entries: rendered.map((entry) => ({
77
+ content: entry.content,
78
+ cwd: entry.cwd,
79
+ fallbackBaseName: 'cursor-thread',
80
+ fileBaseName: entry.fileBaseName,
81
+ sessionId: entry.composerId,
82
+ updatedAtMs: entry.updatedAtMs,
83
+ })),
84
+ fallbackBaseName: 'cursor',
85
+ outputFormat,
86
+ zipArchive: true,
87
+ });
103
88
  };
104
89
 
105
90
  export const findCursorThreadByComposerId = async (composerId: string) => {
@@ -123,13 +108,23 @@ const renderCursorDownload = async (input: {
123
108
  outputFormat: 'md' | 'txt';
124
109
  zipArchive: boolean;
125
110
  }) => {
111
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
126
112
  const { readCursorThreadTranscriptWithAgentFiles } = await import('@spiracha/lib/cursor-db');
127
113
  const { getCursorGlobalDbPath } = await import('@spiracha/lib/cursor-exporter-types');
128
114
  const { renderCursorTranscript } = await import('@spiracha/lib/cursor-transcript');
129
115
  const globalDbPath = getCursorGlobalDbPath();
116
+ const { listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
117
+ const workspaceGroups = await listCursorWorkspaceGroups();
130
118
  const rendered = await Promise.all(
131
119
  input.composerIds.map(async (composerId) => {
132
- const transcript = await readCursorThreadTranscriptWithAgentFiles(globalDbPath, composerId);
120
+ const transcript = await runWithTranscriptLoadLimit(
121
+ () => readCursorThreadTranscriptWithAgentFiles(globalDbPath, composerId),
122
+ {
123
+ id: composerId,
124
+ path: globalDbPath,
125
+ source: 'cursor-ui-export',
126
+ },
127
+ );
133
128
  if (!transcript) {
134
129
  throw new Error(`No transcript found for thread: ${composerId}`);
135
130
  }
@@ -145,9 +140,17 @@ const renderCursorDownload = async (input: {
145
140
  throw new Error(`Thread has no exportable content: ${composerId}`);
146
141
  }
147
142
 
143
+ const workspace =
144
+ workspaceGroups.find((candidate) =>
145
+ candidate.buckets.some((bucket) => bucket.threadComposerIds.includes(composerId)),
146
+ ) ?? null;
147
+
148
148
  return {
149
149
  composerId,
150
150
  content,
151
+ cwd: workspace?.folders[0] ?? null,
152
+ fileBaseName: transcript.head.name || composerId,
153
+ updatedAtMs: transcript.head.lastUpdatedAtMs,
151
154
  };
152
155
  }),
153
156
  );
@@ -157,12 +160,16 @@ const renderCursorDownload = async (input: {
157
160
  }
158
161
 
159
162
  if (rendered.length === 1) {
160
- return {
161
- content: rendered[0]!.content,
162
- fileName: `${toSafeExportName(rendered[0]!.composerId)}.${input.outputFormat}`,
163
- mimeType: getExportMimeType(input.outputFormat),
164
- mode: 'download' as const,
165
- };
163
+ const entry = rendered[0]!;
164
+ return renderSourceSessionDownload({
165
+ content: entry.content,
166
+ cwd: entry.cwd,
167
+ fallbackBaseName: 'cursor-thread',
168
+ outputFormat: input.outputFormat,
169
+ sessionId: entry.composerId,
170
+ updatedAtMs: entry.updatedAtMs,
171
+ zipArchive: false,
172
+ });
166
173
  }
167
174
 
168
175
  throw new Error('No Cursor threads selected for export');
@@ -184,6 +191,7 @@ export const listCursorThreadsFn = createServerFn({ method: 'GET' })
184
191
  export const getCursorThreadDetailFn = createServerFn({ method: 'GET' })
185
192
  .validator(threadSchema)
186
193
  .handler(async ({ data }) => {
194
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
187
195
  const { readCursorThreadTranscriptWithAgentFiles } = await import('@spiracha/lib/cursor-db');
188
196
  const { getCursorGlobalDbPath } = await import('@spiracha/lib/cursor-exporter-types');
189
197
  const thread = await findCursorThreadByComposerId(data.composerId);
@@ -191,7 +199,14 @@ export const getCursorThreadDetailFn = createServerFn({ method: 'GET' })
191
199
  throw new Error(`Cursor thread not found: ${data.composerId}`);
192
200
  }
193
201
 
194
- const transcript = await readCursorThreadTranscriptWithAgentFiles(getCursorGlobalDbPath(), data.composerId);
202
+ const transcript = await runWithTranscriptLoadLimit(
203
+ () => readCursorThreadTranscriptWithAgentFiles(getCursorGlobalDbPath(), data.composerId),
204
+ {
205
+ id: data.composerId,
206
+ path: getCursorGlobalDbPath(),
207
+ source: 'cursor-ui-detail',
208
+ },
209
+ );
195
210
  return {
196
211
  thread,
197
212
  transcript,
@@ -1,6 +1,8 @@
1
1
  import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-types';
2
2
  import type { CursorBubble, CursorThreadTranscript, CursorToolCall } from '@spiracha/lib/cursor-exporter-types';
3
+ import { getCursorTextBubblePhase, getFinalCursorAssistantTextBubbleIds } from '@spiracha/lib/cursor-transcript-phase';
3
4
  import type { JsonValue } from '@spiracha/lib/shared';
5
+ import { getThreadTranscriptStats } from './thread-transcript-stats';
4
6
 
5
7
  const toTimestamp = (value: number | null): string | null => {
6
8
  if (value === null || !Number.isFinite(value)) {
@@ -110,36 +112,10 @@ const buildToolOutputEvent = (bubble: CursorBubble, sequence: number, toolCall:
110
112
  };
111
113
  };
112
114
 
113
- const getAssistantFinalTextIndexes = (bubbles: CursorBubble[]) => {
114
- const finalTextIndexes = new Set<number>();
115
- let currentAssistantTextIndex: number | null = null;
116
-
117
- const flushAssistantRun = () => {
118
- if (currentAssistantTextIndex !== null) {
119
- finalTextIndexes.add(currentAssistantTextIndex);
120
- currentAssistantTextIndex = null;
121
- }
122
- };
123
-
124
- bubbles.forEach((bubble, index) => {
125
- if (bubble.kind !== 'assistant') {
126
- flushAssistantRun();
127
- return;
128
- }
129
-
130
- if (bubble.text.trim()) {
131
- currentAssistantTextIndex = index;
132
- }
133
- });
134
-
135
- flushAssistantRun();
136
- return finalTextIndexes;
137
- };
138
-
139
115
  const cursorBubbleToThreadEvents = (
140
116
  bubble: CursorBubble,
141
117
  bubbleIndex: number,
142
- finalTextIndexes: Set<number>,
118
+ finalAssistantTextBubbleIds: Set<string>,
143
119
  ): ThreadEvent[] => {
144
120
  if (bubble.kind !== 'assistant' && bubble.kind !== 'user') {
145
121
  return [];
@@ -152,9 +128,8 @@ const cursorBubbleToThreadEvents = (
152
128
  }
153
129
 
154
130
  if (bubble.text.trim()) {
155
- const phase =
156
- bubble.kind === 'assistant' ? (finalTextIndexes.has(bubbleIndex) ? 'final_answer' : 'commentary') : null;
157
- events.push(buildMessageEvent(bubble, baseSequence + 1, bubble.kind, bubble.text, phase));
131
+ const phase = getCursorTextBubblePhase(bubble, finalAssistantTextBubbleIds);
132
+ events.push(buildMessageEvent(bubble, baseSequence + 1, bubble.kind, bubble.text.trim(), phase));
158
133
  }
159
134
 
160
135
  if (bubble.kind === 'assistant' && bubble.toolCall) {
@@ -169,61 +144,11 @@ const cursorBubbleToThreadEvents = (
169
144
  };
170
145
 
171
146
  export const cursorTranscriptToThreadEvents = (transcript: CursorThreadTranscript): ThreadEvent[] => {
172
- const finalTextIndexes = getAssistantFinalTextIndexes(transcript.bubbles);
173
- return transcript.bubbles.flatMap((bubble, index) => cursorBubbleToThreadEvents(bubble, index, finalTextIndexes));
174
- };
175
-
176
- const updateMessageStats = (stats: ThreadTranscriptStats, event: Extract<ThreadEvent, { kind: 'message' }>) => {
177
- stats.messageCount += 1;
178
- if (event.role === 'assistant') {
179
- stats.assistantMessageCount += 1;
180
- }
181
- if (event.role === 'user') {
182
- stats.userMessageCount += 1;
183
- }
184
- if (event.phase === 'commentary') {
185
- stats.commentaryCount += 1;
186
- }
187
- if (event.phase === 'final_answer') {
188
- stats.finalAnswerCount += 1;
189
- }
190
- };
191
-
192
- const updateCursorStats = (stats: ThreadTranscriptStats, event: ThreadEvent) => {
193
- switch (event.kind) {
194
- case 'message':
195
- updateMessageStats(stats, event);
196
- break;
197
- case 'tool_call':
198
- stats.toolCallCount += 1;
199
- break;
200
- case 'tool_output':
201
- stats.toolOutputCount += 1;
202
- break;
203
- case 'web_search':
204
- stats.webSearchEventCount += 1;
205
- break;
206
- default:
207
- break;
208
- }
147
+ const finalAssistantTextBubbleIds = getFinalCursorAssistantTextBubbleIds(transcript.bubbles);
148
+ return transcript.bubbles.flatMap((bubble, index) =>
149
+ cursorBubbleToThreadEvents(bubble, index, finalAssistantTextBubbleIds),
150
+ );
209
151
  };
210
152
 
211
- export const getCursorThreadTranscriptStats = (events: ThreadEvent[]): ThreadTranscriptStats => {
212
- const stats: ThreadTranscriptStats = {
213
- assistantMessageCount: 0,
214
- commentaryCount: 0,
215
- execCommandCount: 0,
216
- finalAnswerCount: 0,
217
- messageCount: 0,
218
- toolCallCount: 0,
219
- toolOutputCount: 0,
220
- userMessageCount: 0,
221
- webSearchEventCount: 0,
222
- };
223
-
224
- for (const event of events) {
225
- updateCursorStats(stats, event);
226
- }
227
-
228
- return stats;
229
- };
153
+ export const getCursorThreadTranscriptStats = (events: ThreadEvent[]): ThreadTranscriptStats =>
154
+ getThreadTranscriptStats(events);
@@ -0,0 +1,52 @@
1
+ export class DeleteBatchError<TId, TResult> extends AggregateError {
2
+ readonly failedIds: TId[];
3
+ readonly successfulIds: TId[];
4
+ readonly successfulResults: TResult[];
5
+
6
+ constructor(
7
+ failures: Array<{ id: TId; reason: unknown }>,
8
+ successes: Array<{ id: TId; result: TResult }>,
9
+ totalCount: number,
10
+ ) {
11
+ const visibleFailedIds = failures.slice(0, 5).map((failure) => String(failure.id));
12
+ const omittedFailedIdCount = failures.length - visibleFailedIds.length;
13
+ const failedIdSummary = `${visibleFailedIds.join(', ')}${omittedFailedIdCount > 0 ? ` (+${omittedFailedIdCount} more)` : ''}`;
14
+ super(
15
+ failures.map((failure) => failure.reason),
16
+ `${failures.length} of ${totalCount} deletions failed: ${failedIdSummary}`,
17
+ );
18
+ this.name = 'DeleteBatchError';
19
+ this.failedIds = failures.map((failure) => failure.id);
20
+ this.successfulIds = successes.map((success) => success.id);
21
+ this.successfulResults = successes.map((success) => success.result);
22
+ }
23
+ }
24
+
25
+ export const runDeleteBatch = async <TId, TResult>(
26
+ ids: TId[],
27
+ deleteOne: (id: TId) => Promise<TResult>,
28
+ ): Promise<TResult[]> => {
29
+ const settled = await Promise.allSettled(ids.map((id) => deleteOne(id)));
30
+ const failures: Array<{ id: TId; reason: unknown }> = [];
31
+ const successes: Array<{ id: TId; result: TResult }> = [];
32
+ for (const [index, result] of settled.entries()) {
33
+ const id = ids[index]!;
34
+ if (result.status === 'rejected') {
35
+ failures.push({ id, reason: result.reason });
36
+ } else {
37
+ successes.push({ id, result: result.value });
38
+ }
39
+ }
40
+ if (failures.length > 0) {
41
+ throw new DeleteBatchError(failures, successes, ids.length);
42
+ }
43
+
44
+ return successes.map((success) => success.result);
45
+ };
46
+
47
+ export const requireDeletedItems = <T>(deletedIds: T[], label: string, id: string): T[] => {
48
+ if (deletedIds.length === 0) {
49
+ throw new Error(`${label} not found: ${id}`);
50
+ }
51
+ return deletedIds;
52
+ };
@@ -0,0 +1,43 @@
1
+ import { isSqliteDatabaseError } from '@spiracha/lib/sqlite-error';
2
+
3
+ export type ErrorPresentation = {
4
+ description: string;
5
+ isDatabaseError: boolean;
6
+ title: string;
7
+ };
8
+
9
+ type ErrorPresentationOptions = {
10
+ fallbackTitle: string;
11
+ };
12
+
13
+ const DATABASE_ERROR_DESCRIPTION =
14
+ 'Spiracha could not open a local conversation database. The source application may have an exclusive lock on the file, or the database may not exist yet. Close the relevant application or wait a moment, then reload.';
15
+
16
+ const redactLocalPaths = (message: string): string => {
17
+ return message
18
+ .split(/(https?:\/\/[^\s]+)/gu)
19
+ .map((part, index) =>
20
+ index % 2 === 1
21
+ ? part
22
+ : part
23
+ .replace(/\/(?:[^/\s]+\/)+[^/\s]+/gu, '[local path]')
24
+ .replace(/[A-Z]:\\(?:[^\\\s]+\\)+[^\\\s]+/gu, '[local path]'),
25
+ )
26
+ .join('');
27
+ };
28
+
29
+ export const getErrorPresentation = (error: Error, options: ErrorPresentationOptions): ErrorPresentation => {
30
+ if (isSqliteDatabaseError(error)) {
31
+ return {
32
+ description: DATABASE_ERROR_DESCRIPTION,
33
+ isDatabaseError: true,
34
+ title: 'Database unavailable',
35
+ };
36
+ }
37
+
38
+ return {
39
+ description: redactLocalPaths(error.message),
40
+ isDatabaseError: false,
41
+ title: options.fallbackTitle,
42
+ };
43
+ };
@@ -0,0 +1,20 @@
1
+ import type { ExportDialogOptions } from '#/lib/export-options';
2
+
3
+ export type ExportSelectionMutationInput = Readonly<{
4
+ ids: readonly string[];
5
+ options: Readonly<ExportDialogOptions>;
6
+ }>;
7
+
8
+ export const createExportSelectionMutationInput = (
9
+ ids: readonly string[],
10
+ options: ExportDialogOptions,
11
+ ): ExportSelectionMutationInput => {
12
+ if (ids.length === 0) {
13
+ throw new Error('No conversations selected for export');
14
+ }
15
+
16
+ return {
17
+ ids: [...ids],
18
+ options: { ...options },
19
+ };
20
+ };
@@ -0,0 +1,15 @@
1
+ export type ExportDialogOptions = {
2
+ includeCommentary: boolean;
3
+ includeMetadata: boolean;
4
+ includeTools: boolean;
5
+ outputFormat: 'md' | 'txt';
6
+ zipArchive: boolean;
7
+ };
8
+
9
+ export const DEFAULT_EXPORT_DIALOG_OPTIONS: ExportDialogOptions = {
10
+ includeCommentary: false,
11
+ includeMetadata: true,
12
+ includeTools: true,
13
+ outputFormat: 'md',
14
+ zipArchive: false,
15
+ };
@@ -11,11 +11,9 @@ type DateTimeFormatterSet = {
11
11
  };
12
12
 
13
13
  const DATE_TIME_FORMATTERS = new Map<string, DateTimeFormatterSet>();
14
- const DEFAULT_TIME_ZONE = 'UTC';
15
14
 
16
15
  const getDateTimeFormatters = (timeZone?: string): DateTimeFormatterSet => {
17
- const resolvedTimeZone = timeZone ?? DEFAULT_TIME_ZONE;
18
- const cacheKey = resolvedTimeZone;
16
+ const cacheKey = timeZone ?? 'local';
19
17
  const cached = DATE_TIME_FORMATTERS.get(cacheKey);
20
18
  if (cached) {
21
19
  return cached;
@@ -25,7 +23,7 @@ const getDateTimeFormatters = (timeZone?: string): DateTimeFormatterSet => {
25
23
  dayKeyFormatter: new Intl.DateTimeFormat('en-CA', {
26
24
  day: '2-digit',
27
25
  month: '2-digit',
28
- timeZone: resolvedTimeZone,
26
+ timeZone,
29
27
  year: 'numeric',
30
28
  }),
31
29
  timePartsFormatter: new Intl.DateTimeFormat('en-US', {
@@ -34,7 +32,7 @@ const getDateTimeFormatters = (timeZone?: string): DateTimeFormatterSet => {
34
32
  hour12: true,
35
33
  minute: '2-digit',
36
34
  month: 'short',
37
- timeZone: resolvedTimeZone,
35
+ timeZone,
38
36
  year: 'numeric',
39
37
  }),
40
38
  };
@@ -0,0 +1,22 @@
1
+ import { queryOptions } from '@tanstack/react-query';
2
+ import { getGrokSessionDetailFn, listGrokSessionsFn, listGrokWorkspacesFn } from './grok-server';
3
+
4
+ export const grokWorkspacesQueryOptions = () =>
5
+ queryOptions({
6
+ queryFn: () => listGrokWorkspacesFn(),
7
+ queryKey: ['grok-workspaces'],
8
+ });
9
+
10
+ export const grokSessionsQueryOptions = (workspaceKey: string | null) =>
11
+ queryOptions({
12
+ enabled: workspaceKey !== null,
13
+ queryFn: () => listGrokSessionsFn({ data: { workspaceKey: workspaceKey ?? '' } }),
14
+ queryKey: ['grok-sessions', workspaceKey ?? 'none'],
15
+ });
16
+
17
+ export const grokSessionDetailQueryOptions = (sessionId: string | null) =>
18
+ queryOptions({
19
+ enabled: sessionId !== null,
20
+ queryFn: () => getGrokSessionDetailFn({ data: { sessionId: sessionId ?? '' } }),
21
+ queryKey: ['grok-session', sessionId ?? 'none'],
22
+ });