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,5 +1,5 @@
|
|
|
1
1
|
import type { OpenCodeSessionSummary, OpenCodeWorkspaceGroup } from '@spiracha/lib/opencode-exporter-types';
|
|
2
|
-
import { useMutation,
|
|
2
|
+
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
|
3
3
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
4
4
|
import { Trash2 } from 'lucide-react';
|
|
5
5
|
import { useDeferredValue, useMemo, useState } from 'react';
|
|
@@ -9,9 +9,10 @@ import { ListSearchInput } from '#/components/list-search-input';
|
|
|
9
9
|
import { LoadingPanel } from '#/components/loading-panel';
|
|
10
10
|
import { OpenCodeSessionsTable } from '#/components/opencode-sessions-table';
|
|
11
11
|
import { PageHeader } from '#/components/page-header';
|
|
12
|
-
import {
|
|
12
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
13
13
|
import { Button } from '#/components/ui/button';
|
|
14
14
|
import { downloadTextFile, downloadUrlFile } from '#/lib/download';
|
|
15
|
+
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
|
|
15
16
|
import { openCodeSessionsQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
|
|
16
17
|
import {
|
|
17
18
|
deleteOpenCodeSessionFn,
|
|
@@ -22,14 +23,6 @@ import {
|
|
|
22
23
|
import { matchesTextQuery } from '#/lib/text-filter';
|
|
23
24
|
import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
|
|
24
25
|
|
|
25
|
-
type ExportDialogOptions = {
|
|
26
|
-
includeCommentary: boolean;
|
|
27
|
-
includeMetadata: boolean;
|
|
28
|
-
includeTools: boolean;
|
|
29
|
-
outputFormat: 'md' | 'txt';
|
|
30
|
-
zipArchive: boolean;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
26
|
type PendingSessionDelete = {
|
|
34
27
|
scope: 'all' | 'selected';
|
|
35
28
|
sessions: OpenCodeSessionSummary[];
|
|
@@ -86,47 +79,36 @@ const getDeleteTitle = (pendingDelete: PendingSessionDelete | null) => {
|
|
|
86
79
|
export const Route = createFileRoute('/opencode/$workspaceKey')({
|
|
87
80
|
component: OpenCodeWorkspacePage,
|
|
88
81
|
errorComponent: OpenCodeWorkspaceErrorComponent,
|
|
82
|
+
loader: async ({ context, params }) => {
|
|
83
|
+
const workspaces = await context.queryClient.ensureQueryData(openCodeWorkspacesQueryOptions());
|
|
84
|
+
findWorkspaceOrThrow(workspaces, params.workspaceKey);
|
|
85
|
+
await context.queryClient.ensureQueryData(openCodeSessionsQueryOptions(params.workspaceKey));
|
|
86
|
+
},
|
|
89
87
|
pendingComponent: () => (
|
|
90
88
|
<LoadingPanel description="Loading OpenCode sessions and transcript metadata." title="Loading workspace" />
|
|
91
89
|
),
|
|
92
90
|
});
|
|
93
91
|
|
|
94
92
|
function OpenCodeWorkspaceErrorComponent({ error }: { error: Error }) {
|
|
95
|
-
return <
|
|
93
|
+
return <RouteErrorPanel error={error} title="Failed to load OpenCode workspace" />;
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
function OpenCodeWorkspacePage() {
|
|
101
|
-
const params = Route.useParams();
|
|
102
|
-
const workspacesQuery = useQuery(openCodeWorkspacesQueryOptions());
|
|
103
|
-
const workspaces = workspacesQuery.data ?? [];
|
|
104
|
-
const workspace = workspaces.find((candidate) => candidate.key === params.workspaceKey) ?? null;
|
|
105
|
-
const sessionsQuery = useQuery(openCodeSessionsQueryOptions(workspace?.key ?? null));
|
|
106
|
-
|
|
107
|
-
if (workspacesQuery.isLoading || (workspace && sessionsQuery.isLoading)) {
|
|
108
|
-
return (
|
|
109
|
-
<LoadingPanel description="Loading OpenCode sessions and transcript metadata." title="Loading workspace" />
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (workspacesQuery.isError) {
|
|
114
|
-
return <OpenCodeWorkspaceErrorComponent error={toError(workspacesQuery.error)} />;
|
|
115
|
-
}
|
|
116
|
-
|
|
96
|
+
const findWorkspaceOrThrow = (workspaces: OpenCodeWorkspaceGroup[], workspaceKey: string) => {
|
|
97
|
+
const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
|
|
117
98
|
if (!workspace) {
|
|
118
|
-
|
|
119
|
-
<OpenCodeWorkspaceErrorComponent
|
|
120
|
-
error={new Error(`OpenCode workspace not found: ${params.workspaceKey}`)}
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
99
|
+
throw new Error(`OpenCode workspace not found: ${workspaceKey}`);
|
|
123
100
|
}
|
|
124
101
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
102
|
+
return workspace;
|
|
103
|
+
};
|
|
128
104
|
|
|
129
|
-
|
|
105
|
+
function OpenCodeWorkspacePage() {
|
|
106
|
+
const params = Route.useParams();
|
|
107
|
+
const workspaces = useSuspenseQuery(openCodeWorkspacesQueryOptions()).data;
|
|
108
|
+
const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
|
|
109
|
+
const sessions = useSuspenseQuery(openCodeSessionsQueryOptions(workspace.key)).data;
|
|
110
|
+
|
|
111
|
+
return <OpenCodeWorkspaceContent sessions={sessions} workspace={workspace} />;
|
|
130
112
|
}
|
|
131
113
|
|
|
132
114
|
function OpenCodeWorkspaceContent({
|
|
@@ -144,20 +126,16 @@ function OpenCodeWorkspaceContent({
|
|
|
144
126
|
const deferredSearch = useDeferredValue(searchInput);
|
|
145
127
|
|
|
146
128
|
const exportMutation = useMutation({
|
|
147
|
-
mutationFn: async (options:
|
|
148
|
-
if (!pendingExport) {
|
|
149
|
-
throw new Error('No OpenCode session selected for export');
|
|
150
|
-
}
|
|
151
|
-
|
|
129
|
+
mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
|
|
152
130
|
const download =
|
|
153
|
-
|
|
131
|
+
ids.length === 1
|
|
154
132
|
? await exportOpenCodeSessionFn({
|
|
155
133
|
data: {
|
|
156
134
|
includeCommentary: options.includeCommentary,
|
|
157
135
|
includeMetadata: options.includeMetadata,
|
|
158
136
|
includeTools: options.includeTools,
|
|
159
137
|
outputFormat: options.outputFormat,
|
|
160
|
-
sessionId:
|
|
138
|
+
sessionId: ids[0]!,
|
|
161
139
|
zipArchive: options.zipArchive,
|
|
162
140
|
},
|
|
163
141
|
})
|
|
@@ -167,7 +145,7 @@ function OpenCodeWorkspaceContent({
|
|
|
167
145
|
includeMetadata: options.includeMetadata,
|
|
168
146
|
includeTools: options.includeTools,
|
|
169
147
|
outputFormat: options.outputFormat,
|
|
170
|
-
sessionIds:
|
|
148
|
+
sessionIds: [...ids],
|
|
171
149
|
zipArchive: options.zipArchive,
|
|
172
150
|
},
|
|
173
151
|
});
|
|
@@ -188,13 +166,7 @@ function OpenCodeWorkspaceContent({
|
|
|
188
166
|
sessionIds.length === 1
|
|
189
167
|
? deleteOpenCodeSessionFn({ data: { sessionId: sessionIds[0]! } })
|
|
190
168
|
: deleteOpenCodeSessionsFn({ data: { sessionIds } }),
|
|
191
|
-
|
|
192
|
-
const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
|
|
193
|
-
setPendingDelete(null);
|
|
194
|
-
if (workspaceEmptied) {
|
|
195
|
-
await navigate({ to: '/opencode' });
|
|
196
|
-
}
|
|
197
|
-
|
|
169
|
+
onSettled: async (_result, _error, sessionIds) => {
|
|
198
170
|
await Promise.all([
|
|
199
171
|
queryClient.invalidateQueries({ queryKey: ['opencode-workspaces'] }),
|
|
200
172
|
queryClient.invalidateQueries({ queryKey: ['opencode-sessions', workspace.key] }),
|
|
@@ -203,6 +175,13 @@ function OpenCodeWorkspaceContent({
|
|
|
203
175
|
),
|
|
204
176
|
]);
|
|
205
177
|
},
|
|
178
|
+
onSuccess: async (_result, sessionIds) => {
|
|
179
|
+
const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
|
|
180
|
+
setPendingDelete(null);
|
|
181
|
+
if (workspaceEmptied) {
|
|
182
|
+
await navigate({ to: '/opencode' });
|
|
183
|
+
}
|
|
184
|
+
},
|
|
206
185
|
});
|
|
207
186
|
|
|
208
187
|
const visibleSessions = useMemo(
|
|
@@ -279,11 +258,22 @@ function OpenCodeWorkspaceContent({
|
|
|
279
258
|
/>
|
|
280
259
|
|
|
281
260
|
<ExportDialog
|
|
261
|
+
errorMessage={
|
|
262
|
+
exportMutation.isError
|
|
263
|
+
? exportMutation.error instanceof Error
|
|
264
|
+
? exportMutation.error.message
|
|
265
|
+
: 'Session export failed'
|
|
266
|
+
: null
|
|
267
|
+
}
|
|
282
268
|
forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
|
|
283
269
|
open={pendingExport !== null}
|
|
284
270
|
pending={exportMutation.isPending}
|
|
285
271
|
title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
|
|
286
|
-
onExport={(options) =>
|
|
272
|
+
onExport={(options) => {
|
|
273
|
+
if (pendingExport) {
|
|
274
|
+
exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
|
|
275
|
+
}
|
|
276
|
+
}}
|
|
287
277
|
onOpenChange={(open) => {
|
|
288
278
|
if (!open) {
|
|
289
279
|
setPendingExport(null);
|
|
@@ -292,12 +282,6 @@ function OpenCodeWorkspaceContent({
|
|
|
292
282
|
}}
|
|
293
283
|
/>
|
|
294
284
|
|
|
295
|
-
{exportMutation.isError ? (
|
|
296
|
-
<p className="text-[var(--destructive)] text-sm">
|
|
297
|
-
{exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
|
|
298
|
-
</p>
|
|
299
|
-
) : null}
|
|
300
|
-
|
|
301
285
|
<DeleteConfirmDialog
|
|
302
286
|
confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
|
|
303
287
|
description={getDeleteDescription(pendingDelete)}
|
|
@@ -1,40 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useSuspenseQuery } from '@tanstack/react-query';
|
|
2
2
|
import { createFileRoute } from '@tanstack/react-router';
|
|
3
3
|
import { useDeferredValue, useState } from 'react';
|
|
4
4
|
import { ListSearchInput } from '#/components/list-search-input';
|
|
5
5
|
import { LoadingPanel } from '#/components/loading-panel';
|
|
6
6
|
import { OpenCodeWorkspacesTable } from '#/components/opencode-workspaces-table';
|
|
7
7
|
import { PageHeader } from '#/components/page-header';
|
|
8
|
-
import {
|
|
8
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
9
9
|
import { openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
|
|
10
10
|
import { matchesTextQuery } from '#/lib/text-filter';
|
|
11
11
|
|
|
12
12
|
const OpenCodeErrorComponent = ({ error }: { error: Error }) => {
|
|
13
|
-
return <
|
|
13
|
+
return <RouteErrorPanel error={error} title="Failed to load OpenCode workspaces" />;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
|
|
17
|
-
|
|
18
16
|
const OpenCodePage = () => {
|
|
19
|
-
const
|
|
17
|
+
const workspaces = useSuspenseQuery(openCodeWorkspacesQueryOptions()).data;
|
|
20
18
|
const [searchInput, setSearchInput] = useState('');
|
|
21
19
|
const deferredSearch = useDeferredValue(searchInput);
|
|
22
|
-
const workspaces = workspacesQuery.data ?? [];
|
|
23
20
|
|
|
24
21
|
const visibleWorkspaces = workspaces.filter((workspace) =>
|
|
25
22
|
matchesTextQuery(deferredSearch, [workspace.label, workspace.worktree, workspace.key, workspace.projectId]),
|
|
26
23
|
);
|
|
27
24
|
|
|
28
|
-
if (workspacesQuery.isLoading) {
|
|
29
|
-
return (
|
|
30
|
-
<LoadingPanel description="Loading OpenCode workspaces and database metadata." title="Loading OpenCode" />
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (workspacesQuery.isError) {
|
|
35
|
-
return <OpenCodeErrorComponent error={toError(workspacesQuery.error)} />;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
25
|
return (
|
|
39
26
|
<div className="space-y-6">
|
|
40
27
|
<PageHeader
|
|
@@ -58,4 +45,8 @@ const OpenCodePage = () => {
|
|
|
58
45
|
export const Route = createFileRoute('/opencode/')({
|
|
59
46
|
component: OpenCodePage,
|
|
60
47
|
errorComponent: OpenCodeErrorComponent,
|
|
48
|
+
loader: ({ context }) => context.queryClient.ensureQueryData(openCodeWorkspacesQueryOptions()),
|
|
49
|
+
pendingComponent: () => (
|
|
50
|
+
<LoadingPanel description="Loading OpenCode workspaces and database metadata." title="Loading OpenCode" />
|
|
51
|
+
),
|
|
61
52
|
});
|
|
@@ -11,24 +11,18 @@ import { LoadingPanel } from '#/components/loading-panel';
|
|
|
11
11
|
import { MetadataSection } from '#/components/metadata-section';
|
|
12
12
|
import { MetricCard } from '#/components/metric-card';
|
|
13
13
|
import { PageHeader } from '#/components/page-header';
|
|
14
|
-
import {
|
|
14
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
15
15
|
import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
|
|
16
16
|
import { Button } from '#/components/ui/button';
|
|
17
17
|
import { Checkbox } from '#/components/ui/checkbox';
|
|
18
18
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
|
|
19
19
|
import { downloadTextFile, downloadUrlFile } from '#/lib/download';
|
|
20
|
+
import type { ExportDialogOptions } from '#/lib/export-options';
|
|
20
21
|
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
|
|
21
22
|
import { qoderSessionDetailQueryOptions } from '#/lib/qoder-queries';
|
|
22
23
|
import { exportQoderSessionFn } from '#/lib/qoder-server';
|
|
23
24
|
import { getQoderThreadTranscriptStats, qoderTranscriptToThreadEvents } from '#/lib/qoder-transcript-events';
|
|
24
|
-
|
|
25
|
-
type ExportDialogOptions = {
|
|
26
|
-
includeCommentary: boolean;
|
|
27
|
-
includeMetadata: boolean;
|
|
28
|
-
includeTools: boolean;
|
|
29
|
-
outputFormat: 'md' | 'txt';
|
|
30
|
-
zipArchive: boolean;
|
|
31
|
-
};
|
|
25
|
+
import { RouteStateResetBoundary } from '#/lib/route-state-reset';
|
|
32
26
|
|
|
33
27
|
type TranscriptControlsProps = {
|
|
34
28
|
rawJsonDisabled?: boolean;
|
|
@@ -45,7 +39,7 @@ type TranscriptControlsProps = {
|
|
|
45
39
|
};
|
|
46
40
|
|
|
47
41
|
const QoderSessionDetailErrorComponent = ({ error }: { error: Error }) => {
|
|
48
|
-
return <
|
|
42
|
+
return <RouteErrorPanel error={error} title="Failed to load Qoder session" />;
|
|
49
43
|
};
|
|
50
44
|
|
|
51
45
|
const buildSessionMetadata = (detail: QoderSessionTranscript) => [
|
|
@@ -331,7 +325,14 @@ const QoderSessionDetailPage = () => {
|
|
|
331
325
|
};
|
|
332
326
|
|
|
333
327
|
export const Route = createFileRoute('/qoder-sessions/$sessionId')({
|
|
334
|
-
component:
|
|
328
|
+
component: () => {
|
|
329
|
+
const { sessionId } = Route.useParams();
|
|
330
|
+
return (
|
|
331
|
+
<RouteStateResetBoundary routeKey={sessionId}>
|
|
332
|
+
<QoderSessionDetailPage />
|
|
333
|
+
</RouteStateResetBoundary>
|
|
334
|
+
);
|
|
335
|
+
},
|
|
335
336
|
errorComponent: QoderSessionDetailErrorComponent,
|
|
336
337
|
loader: ({ context, params }) =>
|
|
337
338
|
context.queryClient.ensureQueryData(qoderSessionDetailQueryOptions(params.sessionId)),
|
|
@@ -7,20 +7,13 @@ import { ListSearchInput } from '#/components/list-search-input';
|
|
|
7
7
|
import { LoadingPanel } from '#/components/loading-panel';
|
|
8
8
|
import { PageHeader } from '#/components/page-header';
|
|
9
9
|
import { QoderSessionsTable } from '#/components/qoder-sessions-table';
|
|
10
|
-
import {
|
|
10
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
11
11
|
import { downloadTextFile, downloadUrlFile } from '#/lib/download';
|
|
12
|
+
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
|
|
12
13
|
import { qoderSessionsQueryOptions, qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
|
|
13
14
|
import { exportQoderSessionFn, exportQoderSessionsFn } from '#/lib/qoder-server';
|
|
14
15
|
import { matchesTextQuery } from '#/lib/text-filter';
|
|
15
16
|
|
|
16
|
-
type ExportDialogOptions = {
|
|
17
|
-
includeCommentary: boolean;
|
|
18
|
-
includeMetadata: boolean;
|
|
19
|
-
includeTools: boolean;
|
|
20
|
-
outputFormat: 'md' | 'txt';
|
|
21
|
-
zipArchive: boolean;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
17
|
type PendingSessionExport = {
|
|
25
18
|
label: string;
|
|
26
19
|
sessionIds: string[];
|
|
@@ -36,7 +29,7 @@ const findWorkspaceOrThrow = (workspaces: QoderWorkspaceGroup[], workspaceKey: s
|
|
|
36
29
|
};
|
|
37
30
|
|
|
38
31
|
const QoderWorkspaceErrorComponent = ({ error }: { error: Error }) => {
|
|
39
|
-
return <
|
|
32
|
+
return <RouteErrorPanel error={error} title="Failed to load Qoder workspace" />;
|
|
40
33
|
};
|
|
41
34
|
|
|
42
35
|
const QoderWorkspacePage = () => {
|
|
@@ -49,20 +42,16 @@ const QoderWorkspacePage = () => {
|
|
|
49
42
|
const deferredSearch = useDeferredValue(searchInput);
|
|
50
43
|
|
|
51
44
|
const exportMutation = useMutation({
|
|
52
|
-
mutationFn: async (options:
|
|
53
|
-
if (!pendingExport) {
|
|
54
|
-
throw new Error('No Qoder session selected for export');
|
|
55
|
-
}
|
|
56
|
-
|
|
45
|
+
mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
|
|
57
46
|
const download =
|
|
58
|
-
|
|
47
|
+
ids.length === 1
|
|
59
48
|
? await exportQoderSessionFn({
|
|
60
49
|
data: {
|
|
61
50
|
includeCommentary: options.includeCommentary,
|
|
62
51
|
includeMetadata: options.includeMetadata,
|
|
63
52
|
includeTools: options.includeTools,
|
|
64
53
|
outputFormat: options.outputFormat,
|
|
65
|
-
sessionId:
|
|
54
|
+
sessionId: ids[0]!,
|
|
66
55
|
zipArchive: options.zipArchive,
|
|
67
56
|
},
|
|
68
57
|
})
|
|
@@ -72,7 +61,7 @@ const QoderWorkspacePage = () => {
|
|
|
72
61
|
includeMetadata: options.includeMetadata,
|
|
73
62
|
includeTools: options.includeTools,
|
|
74
63
|
outputFormat: options.outputFormat,
|
|
75
|
-
sessionIds:
|
|
64
|
+
sessionIds: [...ids],
|
|
76
65
|
zipArchive: options.zipArchive,
|
|
77
66
|
},
|
|
78
67
|
});
|
|
@@ -150,11 +139,22 @@ const QoderWorkspacePage = () => {
|
|
|
150
139
|
/>
|
|
151
140
|
|
|
152
141
|
<ExportDialog
|
|
142
|
+
errorMessage={
|
|
143
|
+
exportMutation.isError
|
|
144
|
+
? exportMutation.error instanceof Error
|
|
145
|
+
? exportMutation.error.message
|
|
146
|
+
: 'Session export failed'
|
|
147
|
+
: null
|
|
148
|
+
}
|
|
153
149
|
forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
|
|
154
150
|
open={pendingExport !== null}
|
|
155
151
|
pending={exportMutation.isPending}
|
|
156
152
|
title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
|
|
157
|
-
onExport={(options) =>
|
|
153
|
+
onExport={(options) => {
|
|
154
|
+
if (pendingExport) {
|
|
155
|
+
exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
158
|
onOpenChange={(open) => {
|
|
159
159
|
if (!open) {
|
|
160
160
|
setPendingExport(null);
|
|
@@ -162,12 +162,6 @@ const QoderWorkspacePage = () => {
|
|
|
162
162
|
}
|
|
163
163
|
}}
|
|
164
164
|
/>
|
|
165
|
-
|
|
166
|
-
{exportMutation.isError ? (
|
|
167
|
-
<p className="text-[var(--destructive)] text-sm">
|
|
168
|
-
{exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
|
|
169
|
-
</p>
|
|
170
|
-
) : null}
|
|
171
165
|
</div>
|
|
172
166
|
);
|
|
173
167
|
};
|
|
@@ -4,12 +4,12 @@ import { useDeferredValue, useState } from 'react';
|
|
|
4
4
|
import { ListSearchInput } from '#/components/list-search-input';
|
|
5
5
|
import { PageHeader } from '#/components/page-header';
|
|
6
6
|
import { QoderWorkspacesTable } from '#/components/qoder-workspaces-table';
|
|
7
|
-
import {
|
|
7
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
8
8
|
import { qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
|
|
9
9
|
import { matchesTextQuery } from '#/lib/text-filter';
|
|
10
10
|
|
|
11
11
|
const QoderErrorComponent = ({ error }: { error: Error }) => {
|
|
12
|
-
return <
|
|
12
|
+
return <RouteErrorPanel error={error} title="Failed to load Qoder workspaces" />;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const QoderPage = () => {
|
|
@@ -17,6 +17,9 @@ import { LoadingPanel } from '#/components/loading-panel';
|
|
|
17
17
|
import { MetadataSection } from '#/components/metadata-section';
|
|
18
18
|
import { MetricCard } from '#/components/metric-card';
|
|
19
19
|
import { PageHeader } from '#/components/page-header';
|
|
20
|
+
import { RouteErrorPanel } from '#/components/route-error-panel';
|
|
21
|
+
import { ThreadGoalsPanel } from '#/components/thread-goals-panel';
|
|
22
|
+
import { ThreadToolsPanel } from '#/components/thread-tools-panel';
|
|
20
23
|
import {
|
|
21
24
|
getTranscriptEventKey,
|
|
22
25
|
shouldShowEvent,
|
|
@@ -43,13 +46,16 @@ import {
|
|
|
43
46
|
formatModelLabel,
|
|
44
47
|
formatTokens,
|
|
45
48
|
} from '#/lib/formatters';
|
|
49
|
+
import { getMutationErrorMessage } from '#/lib/mutation-error';
|
|
46
50
|
import { applyPathTransforms } from '#/lib/path-utils';
|
|
47
51
|
import {
|
|
48
52
|
parseThreadTranscriptSearch,
|
|
49
53
|
type ThreadTranscriptSearch,
|
|
50
54
|
withThreadTranscriptSearch,
|
|
51
55
|
} from '#/lib/route-search';
|
|
56
|
+
import { RouteStateResetBoundary } from '#/lib/route-state-reset';
|
|
52
57
|
import { useSettings } from '#/lib/settings-store';
|
|
58
|
+
import { formatSandboxPolicy } from '#/lib/thread-metadata';
|
|
53
59
|
import { shouldLoadFullThreadTranscript } from '#/lib/thread-transcript-load';
|
|
54
60
|
|
|
55
61
|
type ThreadSnapshotResponse = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
|
|
@@ -242,6 +248,7 @@ const buildRuntimeItems = (snapshot: ThreadSnapshot) => {
|
|
|
242
248
|
{ label: 'Reasoning effort', value: snapshot.thread.reasoning_effort ?? 'n/a' },
|
|
243
249
|
{ label: 'CLI version', value: snapshot.thread.cli_version },
|
|
244
250
|
{ label: 'Approval mode', value: snapshot.thread.approval_mode },
|
|
251
|
+
{ label: 'Sandbox policy', value: formatSandboxPolicy(snapshot.thread.sandbox_policy) },
|
|
245
252
|
{ label: 'Memory mode', value: snapshot.thread.memory_mode },
|
|
246
253
|
{ label: 'Has user event', value: formatBooleanLabel(Boolean(snapshot.thread.has_user_event)) },
|
|
247
254
|
];
|
|
@@ -494,29 +501,7 @@ function ThreadMetadataPanels({ snapshot }: ThreadMetadataProps) {
|
|
|
494
501
|
|
|
495
502
|
<div className="space-y-4">
|
|
496
503
|
<MetadataSection items={buildRelationItems(snapshot)} title="Relations and summary" />
|
|
497
|
-
|
|
498
|
-
<section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
|
|
499
|
-
<h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
|
|
500
|
-
Available tools
|
|
501
|
-
</h3>
|
|
502
|
-
<div className="mt-4 space-y-3">
|
|
503
|
-
{snapshot.availableTools.map((tool) => (
|
|
504
|
-
<div
|
|
505
|
-
key={`${tool.name}-${tool.namespace ?? 'global'}`}
|
|
506
|
-
className="rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3.5"
|
|
507
|
-
>
|
|
508
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
509
|
-
<p className="font-medium font-mono text-sm">{tool.name}</p>
|
|
510
|
-
{tool.namespace ? <Badge variant="outline">{tool.namespace}</Badge> : null}
|
|
511
|
-
</div>
|
|
512
|
-
<p className="mt-1.5 text-[var(--muted-foreground)] text-sm">
|
|
513
|
-
{tool.description || 'No description.'}
|
|
514
|
-
</p>
|
|
515
|
-
</div>
|
|
516
|
-
))}
|
|
517
|
-
</div>
|
|
518
|
-
</section>
|
|
519
|
-
|
|
504
|
+
<ThreadGoalsPanel goals={snapshot.goals} />
|
|
520
505
|
<MetadataSection items={buildTranscriptStatsItems(snapshot)} title="Transcript stats" />
|
|
521
506
|
</div>
|
|
522
507
|
</div>
|
|
@@ -609,24 +594,7 @@ function LargeThreadPreviewNotice({
|
|
|
609
594
|
}
|
|
610
595
|
|
|
611
596
|
function ThreadErrorComponent({ error }: { error: Error }) {
|
|
612
|
-
|
|
613
|
-
return (
|
|
614
|
-
<div className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-6 py-10 text-center">
|
|
615
|
-
<p className="font-medium text-[var(--destructive)] text-sm">
|
|
616
|
-
{isSqlite ? 'Database unavailable' : 'Failed to load thread'}
|
|
617
|
-
</p>
|
|
618
|
-
<p className="mt-2 text-[var(--muted-foreground)] text-sm">
|
|
619
|
-
{isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message}
|
|
620
|
-
</p>
|
|
621
|
-
<button
|
|
622
|
-
className="mt-4 text-[var(--accent)] text-sm underline-offset-2 hover:underline"
|
|
623
|
-
type="button"
|
|
624
|
-
onClick={() => window.location.reload()}
|
|
625
|
-
>
|
|
626
|
-
Reload
|
|
627
|
-
</button>
|
|
628
|
-
</div>
|
|
629
|
-
);
|
|
597
|
+
return <RouteErrorPanel error={error} title="Failed to load thread" />;
|
|
630
598
|
}
|
|
631
599
|
|
|
632
600
|
const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown): string | null => {
|
|
@@ -640,16 +608,7 @@ const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown)
|
|
|
640
608
|
export const Route = createFileRoute('/threads/$threadId')({
|
|
641
609
|
component: ThreadDetailPage,
|
|
642
610
|
errorComponent: ThreadErrorComponent,
|
|
643
|
-
loader: ({ context,
|
|
644
|
-
context.queryClient.ensureQueryData(
|
|
645
|
-
threadSnapshotQueryOptions(
|
|
646
|
-
params.threadId,
|
|
647
|
-
(deps as { transcriptFilters: ThreadTranscriptFilters }).transcriptFilters,
|
|
648
|
-
),
|
|
649
|
-
),
|
|
650
|
-
loaderDeps: ({ search }) => ({
|
|
651
|
-
transcriptFilters: getTranscriptFiltersFromSearch(parseThreadTranscriptSearch(search)),
|
|
652
|
-
}),
|
|
611
|
+
loader: ({ context, params }) => context.queryClient.ensureQueryData(threadSnapshotQueryOptions(params.threadId)),
|
|
653
612
|
pendingComponent: () => (
|
|
654
613
|
<LoadingPanel
|
|
655
614
|
description="Loading the transcript, metadata, and parsed event stream for this thread."
|
|
@@ -811,11 +770,47 @@ function ThreadTranscriptTab({
|
|
|
811
770
|
);
|
|
812
771
|
}
|
|
813
772
|
|
|
814
|
-
function
|
|
773
|
+
function ThreadToolsTab({
|
|
774
|
+
loadingTranscript,
|
|
775
|
+
showRawJson,
|
|
776
|
+
snapshot,
|
|
777
|
+
sortOrder,
|
|
778
|
+
transcript,
|
|
779
|
+
onLoadTranscript,
|
|
780
|
+
onSortOrderChange,
|
|
781
|
+
}: {
|
|
782
|
+
loadingTranscript: boolean;
|
|
783
|
+
showRawJson: boolean;
|
|
784
|
+
snapshot: ThreadSnapshot;
|
|
785
|
+
sortOrder: TranscriptSortOrder;
|
|
786
|
+
transcript: ThreadTranscript | null;
|
|
787
|
+
onLoadTranscript: () => void;
|
|
788
|
+
onSortOrderChange: (value: TranscriptSortOrder) => void;
|
|
789
|
+
}) {
|
|
790
|
+
return (
|
|
791
|
+
<TabsContent value="tools">
|
|
792
|
+
<ThreadToolsPanel
|
|
793
|
+
assistantModel={snapshot.thread.model}
|
|
794
|
+
availableTools={snapshot.availableTools}
|
|
795
|
+
events={transcript?.events ?? null}
|
|
796
|
+
loadingTranscript={loadingTranscript}
|
|
797
|
+
projectPath={snapshot.thread.cwd}
|
|
798
|
+
showRawJson={showRawJson && Boolean(transcript?.rawIncluded)}
|
|
799
|
+
sortOrder={sortOrder}
|
|
800
|
+
transcriptIsPartial={transcript?.isPartial}
|
|
801
|
+
transcriptState={transcript ? 'available' : snapshot.transcriptState}
|
|
802
|
+
onLoadTranscript={onLoadTranscript}
|
|
803
|
+
onSortOrderChange={onSortOrderChange}
|
|
804
|
+
/>
|
|
805
|
+
</TabsContent>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function ThreadDetailPageContent() {
|
|
815
810
|
const { navigate, search, transcriptFilters, updateTranscriptSearch } = useThreadTranscriptRouteSearch();
|
|
816
811
|
const queryClient = useQueryClient();
|
|
817
812
|
const params = Route.useParams();
|
|
818
|
-
const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId
|
|
813
|
+
const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId)).data;
|
|
819
814
|
const { settings } = useSettings();
|
|
820
815
|
const transcriptMissing = snapshot.transcriptState === 'missing';
|
|
821
816
|
const [shouldLoadTranscript, setShouldLoadTranscript] = useState(
|
|
@@ -959,6 +954,9 @@ function ThreadDetailPage() {
|
|
|
959
954
|
},
|
|
960
955
|
}),
|
|
961
956
|
onSuccess: async () => {
|
|
957
|
+
queryClient.removeQueries({ queryKey: ['thread', snapshot.thread.id] });
|
|
958
|
+
queryClient.removeQueries({ queryKey: ['thread-transcript-preview', snapshot.thread.id] });
|
|
959
|
+
queryClient.removeQueries({ queryKey: ['thread-transcript', snapshot.thread.id] });
|
|
962
960
|
await Promise.all([
|
|
963
961
|
queryClient.invalidateQueries({ queryKey: ['analytics'] }),
|
|
964
962
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
|
@@ -1027,10 +1025,13 @@ function ThreadDetailPage() {
|
|
|
1027
1025
|
</div>
|
|
1028
1026
|
|
|
1029
1027
|
<Tabs className="space-y-4" defaultValue="transcript">
|
|
1030
|
-
<TabsList className="grid w-
|
|
1028
|
+
<TabsList className="grid w-full grid-cols-4 rounded-full border border-[var(--border)] bg-[var(--panel)] p-1 sm:w-fit sm:min-w-[30rem]">
|
|
1031
1029
|
<TabsTrigger className="rounded-full px-5 text-sm" value="transcript">
|
|
1032
1030
|
Transcript
|
|
1033
1031
|
</TabsTrigger>
|
|
1032
|
+
<TabsTrigger className="rounded-full px-5 text-sm" value="tools">
|
|
1033
|
+
Tools
|
|
1034
|
+
</TabsTrigger>
|
|
1034
1035
|
<TabsTrigger className="rounded-full px-5 text-sm" value="metadata">
|
|
1035
1036
|
Metadata
|
|
1036
1037
|
</TabsTrigger>
|
|
@@ -1066,6 +1067,16 @@ function ThreadDetailPage() {
|
|
|
1066
1067
|
onTranscriptFilterChange={updateTranscriptFilter}
|
|
1067
1068
|
/>
|
|
1068
1069
|
|
|
1070
|
+
<ThreadToolsTab
|
|
1071
|
+
loadingTranscript={transcriptQuery.isFetching}
|
|
1072
|
+
showRawJson={showRawJson}
|
|
1073
|
+
snapshot={viewSnapshot}
|
|
1074
|
+
sortOrder={sortOrder}
|
|
1075
|
+
transcript={transcript}
|
|
1076
|
+
onLoadTranscript={() => setShouldLoadTranscript(true)}
|
|
1077
|
+
onSortOrderChange={updateSortOrder}
|
|
1078
|
+
/>
|
|
1079
|
+
|
|
1069
1080
|
<TabsContent value="metadata">
|
|
1070
1081
|
<ThreadMetadataPanels snapshot={viewSnapshot} />
|
|
1071
1082
|
</TabsContent>
|
|
@@ -1079,11 +1090,17 @@ function ThreadDetailPage() {
|
|
|
1079
1090
|
confirmLabel={deleteThreadMutation.isPending ? 'Deleting...' : 'Delete thread'}
|
|
1080
1091
|
defaultDeleteSessionFiles
|
|
1081
1092
|
description="Delete this thread from the Codex database. Enable Delete Session files if you also want to remove the rollout JSONL from disk."
|
|
1093
|
+
errorMessage={getMutationErrorMessage(deleteThreadMutation.error, 'Thread delete failed')}
|
|
1082
1094
|
open={deleteOpen}
|
|
1083
1095
|
showDeleteSessionFilesOption
|
|
1084
1096
|
title="Delete this thread from Codex DB?"
|
|
1085
1097
|
onConfirm={({ deleteSessionFiles }) => deleteThreadMutation.mutate({ deleteSessionFiles })}
|
|
1086
|
-
onOpenChange={
|
|
1098
|
+
onOpenChange={(open) => {
|
|
1099
|
+
setDeleteOpen(open);
|
|
1100
|
+
if (!open) {
|
|
1101
|
+
deleteThreadMutation.reset();
|
|
1102
|
+
}
|
|
1103
|
+
}}
|
|
1087
1104
|
/>
|
|
1088
1105
|
|
|
1089
1106
|
<ExportDialog
|
|
@@ -1096,8 +1113,22 @@ function ThreadDetailPage() {
|
|
|
1096
1113
|
exportThreadMutation.mutate(options);
|
|
1097
1114
|
}
|
|
1098
1115
|
}}
|
|
1099
|
-
onOpenChange={
|
|
1116
|
+
onOpenChange={(open) => {
|
|
1117
|
+
setExportOpen(open);
|
|
1118
|
+
if (!open) {
|
|
1119
|
+
exportThreadMutation.reset();
|
|
1120
|
+
}
|
|
1121
|
+
}}
|
|
1100
1122
|
/>
|
|
1101
1123
|
</div>
|
|
1102
1124
|
);
|
|
1103
1125
|
}
|
|
1126
|
+
|
|
1127
|
+
function ThreadDetailPage() {
|
|
1128
|
+
const { threadId } = Route.useParams();
|
|
1129
|
+
return (
|
|
1130
|
+
<RouteStateResetBoundary routeKey={threadId}>
|
|
1131
|
+
<ThreadDetailPageContent />
|
|
1132
|
+
</RouteStateResetBoundary>
|
|
1133
|
+
);
|
|
1134
|
+
}
|