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.
- package/AGENTS.md +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -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 +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- 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/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- 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-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- 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 +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- 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 +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- 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 +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- 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 +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- 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 +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- 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,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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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',
|
|
@@ -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);
|
|
@@ -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);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DEFAULT_EXPORT_DIALOG_OPTIONS, type ExportDialogOptions } from '#/lib/export-options';
|
|
2
|
+
|
|
3
|
+
export type Settings = {
|
|
4
|
+
convertToProjectRoot: boolean;
|
|
5
|
+
exportDefaults: ExportDialogOptions;
|
|
6
|
+
redactUsername: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const SETTINGS_COOKIE_NAME = 'spiracha-settings';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_SETTINGS: Settings = {
|
|
12
|
+
convertToProjectRoot: false,
|
|
13
|
+
exportDefaults: DEFAULT_EXPORT_DIALOG_OPTIONS,
|
|
14
|
+
redactUsername: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
18
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
19
|
+
|
|
20
|
+
const booleanOrDefault = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback);
|
|
21
|
+
|
|
22
|
+
const parseExportDefaults = (value: unknown): ExportDialogOptions => {
|
|
23
|
+
const record = asRecord(value);
|
|
24
|
+
if (!record) {
|
|
25
|
+
return DEFAULT_EXPORT_DIALOG_OPTIONS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
includeCommentary: booleanOrDefault(record.includeCommentary, DEFAULT_EXPORT_DIALOG_OPTIONS.includeCommentary),
|
|
30
|
+
includeMetadata: booleanOrDefault(record.includeMetadata, DEFAULT_EXPORT_DIALOG_OPTIONS.includeMetadata),
|
|
31
|
+
includeTools: booleanOrDefault(record.includeTools, DEFAULT_EXPORT_DIALOG_OPTIONS.includeTools),
|
|
32
|
+
outputFormat:
|
|
33
|
+
record.outputFormat === 'txt' || record.outputFormat === 'md'
|
|
34
|
+
? record.outputFormat
|
|
35
|
+
: DEFAULT_EXPORT_DIALOG_OPTIONS.outputFormat,
|
|
36
|
+
zipArchive: booleanOrDefault(record.zipArchive, DEFAULT_EXPORT_DIALOG_OPTIONS.zipArchive),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const normalizeSettings = (value: unknown): Settings => {
|
|
41
|
+
const record = asRecord(value);
|
|
42
|
+
if (!record) {
|
|
43
|
+
return DEFAULT_SETTINGS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
convertToProjectRoot: booleanOrDefault(record.convertToProjectRoot, DEFAULT_SETTINGS.convertToProjectRoot),
|
|
48
|
+
exportDefaults: parseExportDefaults(record.exportDefaults),
|
|
49
|
+
redactUsername: booleanOrDefault(record.redactUsername, DEFAULT_SETTINGS.redactUsername),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const parseSerializedSettings = (serialized: string | null | undefined): Settings => {
|
|
54
|
+
if (!serialized) {
|
|
55
|
+
return DEFAULT_SETTINGS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return normalizeSettings(JSON.parse(decodeURIComponent(serialized)));
|
|
60
|
+
} catch {
|
|
61
|
+
return DEFAULT_SETTINGS;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const serializeSettings = (settings: Settings) => JSON.stringify(settings);
|
|
@@ -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 {
|
|
5
|
+
import {
|
|
6
|
+
buildBatchExportBaseName,
|
|
7
|
+
buildConversationExportBaseName,
|
|
8
|
+
getExportMimeType,
|
|
9
|
+
resolveUniqueExportFileBaseName,
|
|
10
|
+
sanitizeExportFileName,
|
|
11
|
+
} from '@spiracha/lib/ui-export-archive';
|
|
6
12
|
import { buildUiExportDownloadUrl, ensureUiExportDir } from '@spiracha/lib/ui-export-files';
|
|
13
|
+
import { zipExportDirectory } from '@spiracha/lib/ui-export-zip';
|
|
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 =
|
|
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,22 @@
|
|
|
1
|
+
const humanizePolicy = (value: string) => value.replaceAll(/[-_]+/gu, ' ');
|
|
2
|
+
|
|
3
|
+
export const formatSandboxPolicy = (serializedPolicy: string) => {
|
|
4
|
+
const trimmed = serializedPolicy.trim();
|
|
5
|
+
if (!trimmed) {
|
|
6
|
+
return 'n/a';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
11
|
+
if (typeof parsed === 'string') {
|
|
12
|
+
return humanizePolicy(parsed);
|
|
13
|
+
}
|
|
14
|
+
if (parsed && typeof parsed === 'object' && 'type' in parsed && typeof parsed.type === 'string') {
|
|
15
|
+
return humanizePolicy(parsed.type);
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
return humanizePolicy(trimmed);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return humanizePolicy(trimmed);
|
|
22
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-types';
|
|
2
2
|
|
|
3
|
-
const EXEC_TOOL_NAMES = new Set([
|
|
3
|
+
const EXEC_TOOL_NAMES = new Set([
|
|
4
|
+
'bash',
|
|
5
|
+
'exec',
|
|
6
|
+
'exec_command',
|
|
7
|
+
'execute',
|
|
8
|
+
'execute_command',
|
|
9
|
+
'run_command',
|
|
10
|
+
'shell',
|
|
11
|
+
'terminal',
|
|
12
|
+
]);
|
|
4
13
|
|
|
5
14
|
const isExecToolName = (name: string | null | undefined): boolean => {
|
|
6
15
|
return EXEC_TOOL_NAMES.has((name ?? '').trim().toLowerCase());
|