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.
- package/AGENTS.md +27 -12
- package/README.md +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
return transcript.bubbles.flatMap((bubble, index) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
2
|
-
import {
|
|
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
|
|
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:
|
|
13
|
+
settings: DEFAULT_SETTINGS,
|
|
23
14
|
updateSetting: () => {},
|
|
24
15
|
});
|
|
25
16
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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={
|
|
100
|
+
return <SettingsContext.Provider value={contextValue}>{children}</SettingsContext.Provider>;
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
export const useSettings = () => useContext(SettingsContext);
|