spiracha 2.1.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 (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -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
+ };
@@ -1,5 +1,6 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
+ import { runDeleteBatch } from './delete-batch';
3
4
  import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
5
 
5
6
  const workspaceSchema = z.object({
@@ -154,9 +155,7 @@ export const deleteGrokSessionsFn = createServerFn({ method: 'POST' })
154
155
  .handler(async ({ data }) => {
155
156
  const { deleteGrokSession, resolveGrokSessionsDir } = await import('@spiracha/lib/grok-db');
156
157
  const sessionsDir = resolveGrokSessionsDir();
157
- const results = await Promise.all(
158
- data.sessionIds.map((sessionId) => deleteGrokSession(sessionsDir, sessionId)),
159
- );
158
+ const results = await runDeleteBatch(data.sessionIds, (sessionId) => deleteGrokSession(sessionsDir, sessionId));
160
159
  const deletedSessionIds = results.flatMap((result) => result.deletedSessionIds);
161
160
  if (deletedSessionIds.length === 0) {
162
161
  throw new Error('No Grok sessions were deleted');
@@ -1,5 +1,6 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
+ import { requireDeletedItems, runDeleteBatch } from './delete-batch';
3
4
  import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
5
 
5
6
  const workspaceSchema = z.object({
@@ -139,7 +140,9 @@ export const deleteKiroSessionFn = createServerFn({ method: 'POST' })
139
140
  .validator(sessionSchema)
140
141
  .handler(async ({ data }) => {
141
142
  const { deleteKiroSession, resolveKiroWorkspaceSessionsDir } = await import('@spiracha/lib/kiro-db');
142
- return deleteKiroSession(resolveKiroWorkspaceSessionsDir(), data.sessionId);
143
+ const result = await deleteKiroSession(resolveKiroWorkspaceSessionsDir(), data.sessionId);
144
+ requireDeletedItems(result.deletedSessionIds, 'Kiro session', data.sessionId);
145
+ return result;
143
146
  });
144
147
 
145
148
  export const deleteKiroSessionsFn = createServerFn({ method: 'POST' })
@@ -147,5 +150,14 @@ export const deleteKiroSessionsFn = createServerFn({ method: 'POST' })
147
150
  .handler(async ({ data }) => {
148
151
  const { deleteKiroSession, resolveKiroWorkspaceSessionsDir } = await import('@spiracha/lib/kiro-db');
149
152
  const sessionsDir = resolveKiroWorkspaceSessionsDir();
150
- return Promise.all(data.sessionIds.map((sessionId) => deleteKiroSession(sessionsDir, sessionId)));
153
+ const results = await runDeleteBatch(data.sessionIds, (sessionId) => deleteKiroSession(sessionsDir, sessionId));
154
+ requireDeletedItems(
155
+ results.flatMap((result) => result.deletedSessionIds),
156
+ 'Kiro sessions',
157
+ 'batch',
158
+ );
159
+ return {
160
+ deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
161
+ deletedSessionIds: [...new Set(results.flatMap((result) => result.deletedSessionIds))],
162
+ };
151
163
  });
@@ -0,0 +1,15 @@
1
+ export const getMutationErrorMessage = (error: unknown, fallback: string): string | null => {
2
+ if (!error) {
3
+ return null;
4
+ }
5
+
6
+ if (error instanceof Error) {
7
+ return error.message;
8
+ }
9
+
10
+ if (typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message.trim()) {
11
+ return error.message;
12
+ }
13
+
14
+ return fallback;
15
+ };
@@ -1,5 +1,6 @@
1
1
  import { createServerFn } from '@tanstack/react-start';
2
2
  import { z } from 'zod';
3
+ import { requireDeletedItems, runDeleteBatch } from './delete-batch';
3
4
  import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
4
5
 
5
6
  const workspaceSchema = z.object({
@@ -139,7 +140,9 @@ export const deleteOpenCodeSessionFn = createServerFn({ method: 'POST' })
139
140
  .validator(sessionSchema)
140
141
  .handler(async ({ data }) => {
141
142
  const { deleteOpenCodeSession, resolveOpenCodeDbPath } = await import('@spiracha/lib/opencode-db');
142
- return deleteOpenCodeSession(resolveOpenCodeDbPath(), data.sessionId);
143
+ const result = await deleteOpenCodeSession(resolveOpenCodeDbPath(), data.sessionId);
144
+ requireDeletedItems(result.deletedSessionIds, 'OpenCode session', data.sessionId);
145
+ return result;
143
146
  });
144
147
 
145
148
  export const deleteOpenCodeSessionsFn = createServerFn({ method: 'POST' })
@@ -147,5 +150,13 @@ export const deleteOpenCodeSessionsFn = createServerFn({ method: 'POST' })
147
150
  .handler(async ({ data }) => {
148
151
  const { deleteOpenCodeSession, resolveOpenCodeDbPath } = await import('@spiracha/lib/opencode-db');
149
152
  const dbPath = resolveOpenCodeDbPath();
150
- return Promise.all(data.sessionIds.map((sessionId) => deleteOpenCodeSession(dbPath, sessionId)));
153
+ const results = await runDeleteBatch(data.sessionIds, (sessionId) => deleteOpenCodeSession(dbPath, sessionId));
154
+ requireDeletedItems(
155
+ results.flatMap((result) => result.deletedSessionIds),
156
+ 'OpenCode sessions',
157
+ 'batch',
158
+ );
159
+ return {
160
+ deletedSessionIds: [...new Set(results.flatMap((result) => result.deletedSessionIds))],
161
+ };
151
162
  });
@@ -1,5 +1,5 @@
1
1
  import { applyPathTransforms as applySharedPathTransforms } from '@spiracha/lib/path-transforms';
2
- import type { Settings } from './settings-store';
2
+ import type { Settings } from './settings';
3
3
 
4
4
  export const applyPathTransforms = (
5
5
  text: string,
@@ -1,10 +1,21 @@
1
+ import { isRetryableSqliteError } from '@spiracha/lib/sqlite-error';
1
2
  import { queryOptions } from '@tanstack/react-query';
2
3
  import { getQoderSessionDetailFn, listQoderSessionsFn, listQoderWorkspacesFn } from './qoder-server';
3
4
 
5
+ const retrySqliteQuery = (failureCount: number, error: unknown) => {
6
+ return failureCount < 3 && isRetryableSqliteError(error);
7
+ };
8
+
9
+ const retryDelay = (attemptIndex: number) => {
10
+ return [150, 400][attemptIndex] ?? 800;
11
+ };
12
+
4
13
  export const qoderWorkspacesQueryOptions = () =>
5
14
  queryOptions({
6
15
  queryFn: () => listQoderWorkspacesFn(),
7
16
  queryKey: ['qoder-workspaces'],
17
+ retry: retrySqliteQuery,
18
+ retryDelay,
8
19
  });
9
20
 
10
21
  export const qoderSessionsQueryOptions = (workspaceKey: string | null) =>
@@ -12,6 +23,8 @@ export const qoderSessionsQueryOptions = (workspaceKey: string | null) =>
12
23
  enabled: workspaceKey !== null,
13
24
  queryFn: () => listQoderSessionsFn({ data: { workspaceKey: workspaceKey ?? '' } }),
14
25
  queryKey: ['qoder-sessions', workspaceKey ?? 'none'],
26
+ retry: retrySqliteQuery,
27
+ retryDelay,
15
28
  });
16
29
 
17
30
  export const qoderSessionDetailQueryOptions = (sessionId: string | null) =>
@@ -19,4 +32,6 @@ export const qoderSessionDetailQueryOptions = (sessionId: string | null) =>
19
32
  enabled: sessionId !== null,
20
33
  queryFn: () => getQoderSessionDetailFn({ data: { sessionId: sessionId ?? '' } }),
21
34
  queryKey: ['qoder-session', sessionId ?? 'none'],
35
+ retry: retrySqliteQuery,
36
+ retryDelay,
22
37
  });
@@ -57,7 +57,7 @@ const buildToolCallEvent = (
57
57
  ): ThreadEvent => ({
58
58
  argumentsParseFailed: false,
59
59
  argumentsText: command,
60
- callId: entry.entryId,
60
+ callId: getPartString(part, 'toolCallId') ?? entry.entryId,
61
61
  command,
62
62
  kind: 'tool_call',
63
63
  name: getPartString(part, 'toolName') ?? 'qoder_file_operation',
@@ -0,0 +1,10 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+
3
+ type RouteStateResetBoundaryProps = {
4
+ children: ReactNode;
5
+ routeKey: string;
6
+ };
7
+
8
+ export const RouteStateResetBoundary = ({ children, routeKey }: RouteStateResetBoundaryProps) => {
9
+ return <Fragment key={routeKey}>{children}</Fragment>;
10
+ };
@@ -0,0 +1,33 @@
1
+ import { createServerFn } from '@tanstack/react-start';
2
+ import { getCookie, setCookie } from '@tanstack/react-start/server';
3
+ import { z } from 'zod';
4
+ import { parseSerializedSettings, SETTINGS_COOKIE_NAME, serializeSettings } from '#/lib/settings';
5
+
6
+ const exportDefaultsSchema = z.object({
7
+ includeCommentary: z.boolean(),
8
+ includeMetadata: z.boolean(),
9
+ includeTools: z.boolean(),
10
+ outputFormat: z.enum(['md', 'txt']),
11
+ zipArchive: z.boolean(),
12
+ });
13
+
14
+ const settingsSchema = z.object({
15
+ convertToProjectRoot: z.boolean(),
16
+ exportDefaults: exportDefaultsSchema,
17
+ redactUsername: z.boolean(),
18
+ });
19
+
20
+ export const getInitialSettingsFn = createServerFn({ method: 'GET' }).handler(async () =>
21
+ parseSerializedSettings(getCookie(SETTINGS_COOKIE_NAME)),
22
+ );
23
+
24
+ export const saveSettingsFn = createServerFn({ method: 'POST' })
25
+ .validator(settingsSchema)
26
+ .handler(async ({ data }) => {
27
+ setCookie(SETTINGS_COOKIE_NAME, serializeSettings(data), {
28
+ httpOnly: true,
29
+ maxAge: 60 * 60 * 24 * 365,
30
+ path: '/',
31
+ sameSite: 'lax',
32
+ });
33
+ });
@@ -1,59 +1,103 @@
1
- import type { ReactNode } from 'react';
2
- import { createContext, useContext, useEffect, useState } from 'react';
3
-
4
- export type Settings = {
5
- convertToProjectRoot: boolean;
6
- redactUsername: boolean;
7
- };
1
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { DEFAULT_SETTINGS, normalizeSettings, type Settings } from '#/lib/settings';
3
+ import { getInitialSettingsFn, saveSettingsFn } from '#/lib/settings-server';
8
4
 
9
5
  type SettingsContextValue = {
10
6
  settings: Settings;
11
7
  updateSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
12
8
  };
13
9
 
14
- const STORAGE_KEY = 'spiracha-settings';
15
-
16
- const defaultSettings: Settings = {
17
- convertToProjectRoot: false,
18
- redactUsername: false,
19
- };
10
+ const SETTINGS_CHANNEL_NAME = 'spiracha-settings';
20
11
 
21
12
  const SettingsContext = createContext<SettingsContextValue>({
22
- settings: defaultSettings,
13
+ settings: DEFAULT_SETTINGS,
23
14
  updateSetting: () => {},
24
15
  });
25
16
 
26
- const loadSettings = (): Settings => {
27
- if (typeof window === 'undefined') {
28
- return defaultSettings;
29
- }
30
- try {
31
- const stored = window.localStorage.getItem(STORAGE_KEY);
32
- return stored ? { ...defaultSettings, ...(JSON.parse(stored) as Partial<Settings>) } : defaultSettings;
33
- } catch {
34
- return defaultSettings;
35
- }
36
- };
37
-
38
- export function SettingsProvider({ children }: { children: ReactNode }) {
39
- const [settings, setSettings] = useState<Settings>(defaultSettings);
17
+ export function SettingsProvider({
18
+ children,
19
+ initialSettings = DEFAULT_SETTINGS,
20
+ }: {
21
+ children: ReactNode;
22
+ initialSettings?: Settings;
23
+ }) {
24
+ const [settings, setSettings] = useState(initialSettings);
25
+ const channelRef = useRef<BroadcastChannel | null>(null);
26
+ const pendingSaveCountRef = useRef(0);
27
+ const settingsRef = useRef(initialSettings);
28
+ const settingsRevisionRef = useRef(0);
29
+ const synchronizationRequestRef = useRef(0);
30
+ const persistenceQueueRef = useRef(Promise.resolve());
40
31
 
41
- // Load from localStorage on mount (client only — avoids SSR hydration mismatch)
42
32
  useEffect(() => {
43
- setSettings(loadSettings());
33
+ const synchronizeFromCookie = () => {
34
+ const hadPendingSave = pendingSaveCountRef.current > 0;
35
+ const requestId = synchronizationRequestRef.current + 1;
36
+ const revision = settingsRevisionRef.current;
37
+ synchronizationRequestRef.current = requestId;
38
+ void getInitialSettingsFn()
39
+ .then((savedSettings) => {
40
+ if (
41
+ hadPendingSave ||
42
+ pendingSaveCountRef.current > 0 ||
43
+ requestId !== synchronizationRequestRef.current ||
44
+ revision !== settingsRevisionRef.current
45
+ ) {
46
+ return;
47
+ }
48
+ settingsRef.current = savedSettings;
49
+ setSettings(savedSettings);
50
+ })
51
+ .catch((error: unknown) => {
52
+ console.error('[spiracha:settings] synchronization failed', {
53
+ error: error instanceof Error ? error.message : String(error),
54
+ });
55
+ });
56
+ };
57
+ const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(SETTINGS_CHANNEL_NAME);
58
+ if (channel) {
59
+ channel.onmessage = (event: MessageEvent<unknown>) => {
60
+ if (pendingSaveCountRef.current > 0) {
61
+ return;
62
+ }
63
+ const synchronizedSettings = normalizeSettings(event.data);
64
+ settingsRevisionRef.current += 1;
65
+ settingsRef.current = synchronizedSettings;
66
+ setSettings(synchronizedSettings);
67
+ };
68
+ channelRef.current = channel;
69
+ }
70
+ window.addEventListener('focus', synchronizeFromCookie);
71
+
72
+ return () => {
73
+ window.removeEventListener('focus', synchronizeFromCookie);
74
+ channel?.close();
75
+ channelRef.current = null;
76
+ };
44
77
  }, []);
45
78
 
46
- const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
47
- setSettings((prev) => {
48
- const next = { ...prev, [key]: value };
79
+ const updateSetting = useCallback(<K extends keyof Settings>(key: K, value: Settings[K]) => {
80
+ const nextSettings = { ...settingsRef.current, [key]: value };
81
+ pendingSaveCountRef.current += 1;
82
+ settingsRevisionRef.current += 1;
83
+ settingsRef.current = nextSettings;
84
+ setSettings(nextSettings);
85
+ persistenceQueueRef.current = persistenceQueueRef.current.then(async () => {
49
86
  try {
50
- localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
51
- } catch {}
52
- return next;
87
+ await saveSettingsFn({ data: nextSettings });
88
+ channelRef.current?.postMessage(nextSettings);
89
+ } catch (error) {
90
+ console.error('[spiracha:settings] persistence failed', {
91
+ error: error instanceof Error ? error.message : String(error),
92
+ });
93
+ } finally {
94
+ pendingSaveCountRef.current -= 1;
95
+ }
53
96
  });
54
- };
97
+ }, []);
98
+ const contextValue = useMemo(() => ({ settings, updateSetting }), [settings, updateSetting]);
55
99
 
56
- return <SettingsContext.Provider value={{ settings, updateSetting }}>{children}</SettingsContext.Provider>;
100
+ return <SettingsContext.Provider value={contextValue}>{children}</SettingsContext.Provider>;
57
101
  }
58
102
 
59
103
  export const useSettings = () => useContext(SettingsContext);