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,6 +1,6 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
- import { renderSourceSessionDownload } from './source-session-export-server';
3
+ import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
4
 
5
5
  const workspaceSchema = z.object({
6
6
  workspaceKey: z.string().min(1),
@@ -19,6 +19,15 @@ const exportSessionSchema = z.object({
19
19
  zipArchive: z.boolean().default(false),
20
20
  });
21
21
 
22
+ const exportSessionsSchema = z.object({
23
+ includeCommentary: z.boolean().default(true),
24
+ includeMetadata: z.boolean().default(true),
25
+ includeTools: z.boolean().default(true),
26
+ outputFormat: z.enum(['md', 'txt']).default('md'),
27
+ sessionIds: z.array(z.string().min(1)).min(1),
28
+ zipArchive: z.boolean().default(true),
29
+ });
30
+
22
31
  export const listQoderWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
23
32
  const { listQoderWorkspaceGroups } = await import('@spiracha/lib/qoder-db');
24
33
  return listQoderWorkspaceGroups();
@@ -32,19 +41,27 @@ export const listQoderSessionsFn = createServerFn({ method: 'GET' })
32
41
  });
33
42
 
34
43
  const loadQoderSessionTranscript = async (sessionId: string) => {
44
+ const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
35
45
  const { readQoderSessionTranscript, resolveQoderGlobalStateDb, resolveQoderWorkspaceStorageDir } = await import(
36
46
  '@spiracha/lib/qoder-db'
37
47
  );
38
- const transcript = await readQoderSessionTranscript(
39
- resolveQoderGlobalStateDb(),
40
- resolveQoderWorkspaceStorageDir(),
41
- sessionId,
42
- );
43
- if (!transcript) {
44
- throw new Error(`Qoder session not found: ${sessionId}`);
45
- }
48
+ const globalStateDb = resolveQoderGlobalStateDb();
49
+ const workspaceStorageDir = resolveQoderWorkspaceStorageDir();
50
+ return runWithTranscriptLoadLimit(
51
+ async () => {
52
+ const transcript = await readQoderSessionTranscript(globalStateDb, workspaceStorageDir, sessionId);
53
+ if (!transcript) {
54
+ throw new Error(`Qoder session not found: ${sessionId}`);
55
+ }
46
56
 
47
- return transcript;
57
+ return transcript;
58
+ },
59
+ {
60
+ id: sessionId,
61
+ path: globalStateDb,
62
+ source: 'qoder-ui',
63
+ },
64
+ );
48
65
  };
49
66
 
50
67
  export const getQoderSessionDetailFn = createServerFn({ method: 'GET' })
@@ -71,8 +88,47 @@ export const exportQoderSessionFn = createServerFn({ method: 'POST' })
71
88
 
72
89
  return renderSourceSessionDownload({
73
90
  content,
91
+ cwd: transcript.session.workspacePath ?? transcript.session.worktree,
74
92
  fallbackBaseName: 'qoder-session',
75
- fileBaseName: transcript.session.title || transcript.session.sessionId,
93
+ outputFormat: data.outputFormat,
94
+ sessionId: transcript.session.sessionId,
95
+ updatedAtMs: transcript.session.lastActiveAtMs,
96
+ zipArchive: data.zipArchive,
97
+ });
98
+ });
99
+
100
+ export const exportQoderSessionsFn = createServerFn({ method: 'POST' })
101
+ .validator(exportSessionsSchema)
102
+ .handler(async ({ data }) => {
103
+ const { renderQoderTranscript } = await import('@spiracha/lib/qoder-transcript');
104
+ const entries = await Promise.all(
105
+ data.sessionIds.map(async (sessionId) => {
106
+ const transcript = await loadQoderSessionTranscript(sessionId);
107
+ const content = renderQoderTranscript(transcript, {
108
+ includeCommentary: data.includeCommentary,
109
+ includeMetadata: data.includeMetadata,
110
+ includeTools: data.includeTools,
111
+ outputFormat: data.outputFormat,
112
+ });
113
+
114
+ if (!content) {
115
+ throw new Error(`Qoder session has no exportable content: ${sessionId}`);
116
+ }
117
+
118
+ return {
119
+ content,
120
+ cwd: transcript.session.workspacePath ?? transcript.session.worktree,
121
+ fallbackBaseName: 'qoder-session',
122
+ fileBaseName: transcript.session.title || transcript.session.sessionId,
123
+ sessionId: transcript.session.sessionId,
124
+ updatedAtMs: transcript.session.lastActiveAtMs,
125
+ };
126
+ }),
127
+ );
128
+
129
+ return renderSourceSessionsDownload({
130
+ entries,
131
+ fallbackBaseName: 'qoder-sessions',
76
132
  outputFormat: data.outputFormat,
77
133
  zipArchive: data.zipArchive,
78
134
  });
@@ -6,6 +6,16 @@ export type AnalyticsSearch = {
6
6
  project?: string;
7
7
  };
8
8
 
9
+ export type ThreadTranscriptSearch = {
10
+ commentary?: boolean;
11
+ extra?: boolean;
12
+ q?: string;
13
+ raw?: boolean;
14
+ sort?: 'earliest' | 'latest';
15
+ tools?: boolean;
16
+ user?: boolean;
17
+ };
18
+
9
19
  type SearchRecord = Record<string, unknown>;
10
20
 
11
21
  const asNonBlankString = (value: unknown) => {
@@ -27,6 +37,110 @@ export const parseAnalyticsSearch = (search: SearchRecord): AnalyticsSearch => {
27
37
  return project ? { project } : {};
28
38
  };
29
39
 
40
+ const asBooleanSearch = (value: unknown) => {
41
+ if (value === true || value === 'true' || value === '1') {
42
+ return true;
43
+ }
44
+
45
+ if (value === false || value === 'false' || value === '0') {
46
+ return false;
47
+ }
48
+
49
+ return undefined;
50
+ };
51
+
52
+ const setBooleanSearchParam = (target: SearchRecord, key: keyof ThreadTranscriptSearch, value: boolean) => {
53
+ if (value) {
54
+ target[key] = true;
55
+ return;
56
+ }
57
+
58
+ delete target[key];
59
+ };
60
+
61
+ const setTextSearchParam = (target: SearchRecord, key: keyof ThreadTranscriptSearch, value: string | undefined) => {
62
+ const trimmed = asNonBlankString(value);
63
+ if (trimmed) {
64
+ target[key] = trimmed;
65
+ return;
66
+ }
67
+
68
+ delete target[key];
69
+ };
70
+
71
+ const setThreadSortSearchParam = (target: SearchRecord, value: ThreadTranscriptSearch['sort']) => {
72
+ if (value === 'latest') {
73
+ target.sort = 'latest';
74
+ return;
75
+ }
76
+
77
+ delete target.sort;
78
+ };
79
+
80
+ export const parseThreadTranscriptSearch = (search: SearchRecord): ThreadTranscriptSearch => {
81
+ const q = asNonBlankString(search.q);
82
+ const sort = search.sort === 'latest' ? 'latest' : undefined;
83
+ const parsed: ThreadTranscriptSearch = q ? { q } : {};
84
+ const tools = asBooleanSearch(search.tools);
85
+ const commentary = asBooleanSearch(search.commentary);
86
+ const extra = asBooleanSearch(search.extra);
87
+ const raw = asBooleanSearch(search.raw);
88
+ const user = asBooleanSearch(search.user);
89
+
90
+ if (tools) {
91
+ parsed.tools = true;
92
+ }
93
+ if (commentary) {
94
+ parsed.commentary = true;
95
+ }
96
+ if (extra) {
97
+ parsed.extra = true;
98
+ }
99
+ if (raw) {
100
+ parsed.raw = true;
101
+ }
102
+ if (user) {
103
+ parsed.user = true;
104
+ }
105
+ if (sort) {
106
+ parsed.sort = sort;
107
+ }
108
+
109
+ return parsed;
110
+ };
111
+
112
+ export const withThreadTranscriptSearch = (
113
+ current: SearchRecord,
114
+ patch: Partial<ThreadTranscriptSearch>,
115
+ ): SearchRecord & ThreadTranscriptSearch => {
116
+ const next = { ...current };
117
+
118
+ if ('q' in patch) {
119
+ setTextSearchParam(next, 'q', patch.q);
120
+ }
121
+ if ('sort' in patch) {
122
+ setThreadSortSearchParam(next, patch.sort);
123
+ }
124
+
125
+ if ('tools' in patch && typeof patch.tools === 'boolean') {
126
+ setBooleanSearchParam(next, 'tools', patch.tools);
127
+ }
128
+ if ('commentary' in patch && typeof patch.commentary === 'boolean') {
129
+ setBooleanSearchParam(next, 'commentary', patch.commentary);
130
+ }
131
+ if ('extra' in patch && typeof patch.extra === 'boolean') {
132
+ setBooleanSearchParam(next, 'extra', patch.extra);
133
+ }
134
+ if ('raw' in patch && typeof patch.raw === 'boolean') {
135
+ setBooleanSearchParam(next, 'raw', patch.raw);
136
+ }
137
+ if ('user' in patch && typeof patch.user === 'boolean') {
138
+ setBooleanSearchParam(next, 'user', patch.user);
139
+ }
140
+
141
+ return next as SearchRecord & ThreadTranscriptSearch;
142
+ };
143
+
30
144
  export const withTextQuerySearch = (current: SearchRecord, query: string): SearchRecord & TextQuerySearch => {
31
145
  const next = { ...current };
32
146
  const q = asNonBlankString(query);
@@ -2,15 +2,40 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdtemp, rm } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { getExportMimeType, sanitizeExportFileName, zipExportDirectory } from '@spiracha/lib/ui-export-archive';
5
+ import {
6
+ buildBatchExportBaseName,
7
+ buildConversationExportBaseName,
8
+ getExportMimeType,
9
+ resolveUniqueExportFileBaseName,
10
+ sanitizeExportFileName,
11
+ zipExportDirectory,
12
+ } from '@spiracha/lib/ui-export-archive';
6
13
  import { buildUiExportDownloadUrl, ensureUiExportDir } from '@spiracha/lib/ui-export-files';
7
14
 
8
15
  type ExportFormat = 'md' | 'txt';
9
16
 
10
17
  type RenderSourceSessionDownloadOptions = {
11
18
  content: string;
19
+ cwd: string | null;
20
+ fallbackBaseName: string;
21
+ outputFormat: ExportFormat;
22
+ sessionId: string;
23
+ updatedAtMs: number | null;
24
+ zipArchive: boolean;
25
+ };
26
+
27
+ type RenderedSourceSession = {
28
+ content: string;
29
+ cwd: string | null;
12
30
  fallbackBaseName: string;
13
31
  fileBaseName: string;
32
+ sessionId: string;
33
+ updatedAtMs: number | null;
34
+ };
35
+
36
+ type RenderSourceSessionsDownloadOptions = {
37
+ entries: RenderedSourceSession[];
38
+ fallbackBaseName: string;
14
39
  outputFormat: ExportFormat;
15
40
  zipArchive: boolean;
16
41
  };
@@ -21,12 +46,21 @@ export const toSafeSourceExportName = (value: string, fallback: string) => {
21
46
 
22
47
  export const renderSourceSessionDownload = async ({
23
48
  content,
49
+ cwd,
24
50
  fallbackBaseName,
25
- fileBaseName,
26
51
  outputFormat,
52
+ sessionId,
53
+ updatedAtMs,
27
54
  zipArchive,
28
55
  }: RenderSourceSessionDownloadOptions) => {
29
- const safeBaseName = toSafeSourceExportName(fileBaseName, fallbackBaseName);
56
+ const safeBaseName = buildConversationExportBaseName(
57
+ {
58
+ cwd,
59
+ id: sessionId,
60
+ updatedAtMs,
61
+ },
62
+ fallbackBaseName,
63
+ );
30
64
  if (!zipArchive) {
31
65
  return {
32
66
  content,
@@ -54,3 +88,52 @@ export const renderSourceSessionDownload = async ({
54
88
  mode: 'download_url' as const,
55
89
  };
56
90
  };
91
+
92
+ export const renderSourceSessionsDownload = async ({
93
+ entries,
94
+ fallbackBaseName,
95
+ outputFormat,
96
+ zipArchive,
97
+ }: RenderSourceSessionsDownloadOptions) => {
98
+ if (entries.length === 0) {
99
+ throw new Error('No transcripts selected for export');
100
+ }
101
+
102
+ if (entries.length === 1) {
103
+ const entry = entries[0]!;
104
+ return renderSourceSessionDownload({
105
+ content: entry.content,
106
+ cwd: entry.cwd,
107
+ fallbackBaseName: entry.fallbackBaseName,
108
+ outputFormat,
109
+ sessionId: entry.sessionId,
110
+ updatedAtMs: entry.updatedAtMs,
111
+ zipArchive,
112
+ });
113
+ }
114
+
115
+ const safeBaseName = buildBatchExportBaseName(entries, fallbackBaseName);
116
+ const exportDir = await ensureUiExportDir();
117
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${safeBaseName}-`));
118
+ const zipPath = path.join(exportDir, `${safeBaseName}-${randomUUID()}.zip`);
119
+ const usedBaseNames = new Map<string, number>();
120
+
121
+ try {
122
+ for (const entry of entries) {
123
+ const baseName = toSafeSourceExportName(entry.fileBaseName, entry.fallbackBaseName);
124
+ const fileBaseName = resolveUniqueExportFileBaseName(baseName, usedBaseNames);
125
+ await Bun.write(path.join(workspaceDir, `${fileBaseName}.${outputFormat}`), entry.content);
126
+ }
127
+
128
+ await zipExportDirectory(workspaceDir, zipPath);
129
+ } finally {
130
+ await rm(workspaceDir, { force: true, recursive: true });
131
+ }
132
+
133
+ return {
134
+ downloadUrl: buildUiExportDownloadUrl(zipPath),
135
+ fileName: `${safeBaseName}.zip`,
136
+ mimeType: 'application/zip',
137
+ mode: 'download_url' as const,
138
+ };
139
+ };
@@ -0,0 +1,15 @@
1
+ type TranscriptLoadState = {
2
+ isPartial: boolean;
3
+ } | null;
4
+
5
+ export const shouldLoadFullThreadTranscript = ({
6
+ shouldLoadTranscript,
7
+ snapshotTranscript,
8
+ transcriptMissing,
9
+ }: {
10
+ shouldLoadTranscript: boolean;
11
+ snapshotTranscript: TranscriptLoadState;
12
+ transcriptMissing: boolean;
13
+ }) => {
14
+ return shouldLoadTranscript && !transcriptMissing && (snapshotTranscript === null || snapshotTranscript.isPartial);
15
+ };
@@ -0,0 +1,12 @@
1
+ export const isWorkspaceEmptiedByDelete = <TItem>(
2
+ items: TItem[],
3
+ deletedIds: string[],
4
+ getItemId: (item: TItem) => string,
5
+ ) => {
6
+ if (items.length === 0 || deletedIds.length === 0) {
7
+ return false;
8
+ }
9
+
10
+ const deletedIdSet = new Set(deletedIds);
11
+ return items.every((item) => deletedIdSet.has(getItemId(item)));
12
+ };