@swarmclawai/swarmclaw 1.9.35 → 1.9.37

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.
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useMemo, type DragEvent } from 'react'
3
+ import { useCallback, useEffect, useMemo, useRef, type DragEvent } from 'react'
4
4
  import {
5
5
  ReactFlow,
6
6
  Background,
@@ -12,16 +12,19 @@ import {
12
12
  type NodeChange,
13
13
  type EdgeChange,
14
14
  type Node,
15
+ type Edge,
16
+ type ReactFlowInstance,
15
17
  } from '@xyflow/react'
16
18
  import '@xyflow/react/dist/style.css'
17
- import { useProtocolBuilderStore, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
19
+ import { useProtocolBuilderStore, type BuilderEdgeData, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
18
20
  import { getNodeTypeForKind } from '@/features/protocols/builder/utils/template-to-nodes'
21
+ import { isBuilderTemplateReadOnly } from '@/features/protocols/builder/utils/builder-template-access'
19
22
  import { PhaseNode, BranchNode, LoopNode, ParallelNode, JoinNode, ForEachNode, SubflowNode, SwarmNode, CompleteNode } from './node-types'
20
23
  import { DefaultEdge, BranchEdge, LoopEdge } from './edge-types'
21
24
  import { NodePalette } from './node-palette'
22
25
  import { NodeInspector } from './node-inspector'
23
26
  import { ValidationPanel } from './validation-panel'
24
- import type { ProtocolStepKind } from '@/types'
27
+ import type { ProtocolStepKind, ProtocolTemplate } from '@/types'
25
28
 
26
29
  const nodeTypes = {
27
30
  phase: PhaseNode,
@@ -41,6 +44,54 @@ const edgeTypes = {
41
44
  loop: LoopEdge,
42
45
  }
43
46
 
47
+ function BuiltInTemplatePanel({
48
+ template,
49
+ onSelectStep,
50
+ }: {
51
+ template: ProtocolTemplate | null
52
+ onSelectStep: (stepId: string) => void
53
+ }) {
54
+ const steps = template?.steps && template.steps.length > 0
55
+ ? template.steps
56
+ : template?.defaultPhases ?? []
57
+
58
+ return (
59
+ <div className="flex w-52 shrink-0 flex-col overflow-y-auto rounded-lg border bg-card p-3 shadow-sm">
60
+ <div className="mb-3">
61
+ <div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
62
+ Built-in template
63
+ </div>
64
+ <div className="mt-1 text-sm font-semibold text-foreground">{template?.name || 'Template'}</div>
65
+ {template?.description && (
66
+ <div className="mt-1 text-xs leading-relaxed text-muted-foreground">
67
+ {template.description}
68
+ </div>
69
+ )}
70
+ </div>
71
+
72
+ <div className="space-y-1">
73
+ {steps.map((step, index) => (
74
+ <button
75
+ key={step.id}
76
+ type="button"
77
+ onClick={() => onSelectStep(step.id)}
78
+ className="w-full rounded-md border bg-background px-3 py-2 text-left"
79
+ title={step.kind.replace(/_/g, ' ')}
80
+ >
81
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
82
+ Step {index + 1}
83
+ </div>
84
+ <div className="mt-1 text-sm font-medium text-foreground">{step.label}</div>
85
+ <div className="mt-1 text-xs capitalize text-muted-foreground">
86
+ {step.kind.replace(/_/g, ' ')}
87
+ </div>
88
+ </button>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
44
95
  export function ProtocolBuilderCanvas() {
45
96
  const nodes = useProtocolBuilderStore((s) => s.nodes)
46
97
  const edges = useProtocolBuilderStore((s) => s.edges)
@@ -54,23 +105,44 @@ export function ProtocolBuilderCanvas() {
54
105
  const isDirty = useProtocolBuilderStore((s) => s.isDirty)
55
106
  const undo = useProtocolBuilderStore((s) => s.undo)
56
107
  const redo = useProtocolBuilderStore((s) => s.redo)
108
+ const currentTemplate = useProtocolBuilderStore((s) => s.currentTemplate)
109
+ const flowRef = useRef<ReactFlowInstance<Node<BuilderNodeData>, Edge<BuilderEdgeData>> | null>(null)
110
+
111
+ const readOnly = isBuilderTemplateReadOnly(currentTemplate)
112
+
113
+ useEffect(() => {
114
+ if (nodes.length === 0) return
115
+ const frame = window.requestAnimationFrame(() => {
116
+ void flowRef.current?.fitView({ padding: 0.24, duration: 160 })
117
+ })
118
+ return () => window.cancelAnimationFrame(frame)
119
+ }, [nodes.length, edges.length])
57
120
 
58
121
  const onNodesChange = useCallback(
59
122
  (changes: NodeChange<Node<BuilderNodeData>>[]) => {
60
- setNodes(applyNodeChanges(changes, nodes))
123
+ const allowedChanges = readOnly
124
+ ? changes.filter((change) => change.type === 'select')
125
+ : changes
126
+ if (allowedChanges.length === 0) return
127
+ setNodes(applyNodeChanges(allowedChanges, nodes), { markDirty: !readOnly })
61
128
  },
62
- [nodes, setNodes],
129
+ [nodes, readOnly, setNodes],
63
130
  )
64
131
 
65
132
  const onEdgesChange = useCallback(
66
133
  (changes: EdgeChange[]) => {
67
- setEdges(applyEdgeChanges(changes, edges) as typeof edges)
134
+ const allowedChanges = readOnly
135
+ ? changes.filter((change) => change.type === 'select')
136
+ : changes
137
+ if (allowedChanges.length === 0) return
138
+ setEdges(applyEdgeChanges(allowedChanges, edges) as typeof edges, { markDirty: !readOnly })
68
139
  },
69
- [edges, setEdges],
140
+ [edges, readOnly, setEdges],
70
141
  )
71
142
 
72
143
  const onConnect = useCallback(
73
144
  (connection: Connection) => {
145
+ if (readOnly) return
74
146
  pushUndo()
75
147
  addEdge({
76
148
  id: `${connection.source}--${connection.target}--${Date.now()}`,
@@ -82,7 +154,7 @@ export function ProtocolBuilderCanvas() {
82
154
  data: { edgeType: 'default' },
83
155
  })
84
156
  },
85
- [addEdge, pushUndo],
157
+ [addEdge, pushUndo, readOnly],
86
158
  )
87
159
 
88
160
  const onNodeClick = useCallback(
@@ -112,6 +184,7 @@ export function ProtocolBuilderCanvas() {
112
184
  const onDrop = useCallback(
113
185
  (e: DragEvent) => {
114
186
  e.preventDefault()
187
+ if (readOnly) return
115
188
  const kind = e.dataTransfer.getData('application/x-protocol-node-kind') as ProtocolStepKind
116
189
  const label = e.dataTransfer.getData('application/x-protocol-node-label')
117
190
  if (!kind) return
@@ -127,32 +200,43 @@ export function ProtocolBuilderCanvas() {
127
200
  }
128
201
  addNode(newNode)
129
202
  },
130
- [addNode, pushUndo],
203
+ [addNode, pushUndo, readOnly],
131
204
  )
132
205
 
133
206
  const onKeyDown = useCallback(
134
207
  (e: React.KeyboardEvent) => {
208
+ if (readOnly) return
135
209
  if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
136
210
  e.preventDefault()
137
211
  if (e.shiftKey) redo()
138
212
  else undo()
139
213
  }
140
214
  },
141
- [undo, redo],
215
+ [readOnly, undo, redo],
142
216
  )
143
217
 
144
218
  const memoizedNodeTypes = useMemo(() => nodeTypes, [])
145
219
  const memoizedEdgeTypes = useMemo(() => edgeTypes, [])
146
220
 
147
221
  return (
148
- <div className="flex h-full w-full gap-3" onKeyDown={onKeyDown} tabIndex={0}>
149
- <NodePalette />
150
- <div className="relative flex-1 overflow-hidden rounded-lg border">
222
+ <div className="flex h-full min-h-0 w-full min-w-0 gap-3" onKeyDown={onKeyDown} tabIndex={0}>
223
+ {readOnly ? <BuiltInTemplatePanel template={currentTemplate} onSelectStep={selectNode} /> : <NodePalette />}
224
+ <div className="relative min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg border">
151
225
  {isDirty && (
152
226
  <div className="absolute left-1/2 top-2 z-10 -translate-x-1/2 rounded bg-amber-500/10 px-2 py-1 text-xs text-amber-500">
153
227
  Unsaved changes
154
228
  </div>
155
229
  )}
230
+ {nodes.length === 0 && (
231
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
232
+ <div className="pointer-events-auto max-w-md rounded-lg border bg-card/95 p-4 text-center shadow-sm">
233
+ <div className="text-sm font-semibold text-foreground">No visual steps</div>
234
+ <div className="mt-1 text-sm text-muted-foreground">
235
+ This template does not expose a protocol graph yet.
236
+ </div>
237
+ </div>
238
+ </div>
239
+ )}
156
240
  <ReactFlow
157
241
  nodes={nodes}
158
242
  edges={edges}
@@ -166,8 +250,15 @@ export function ProtocolBuilderCanvas() {
166
250
  onPaneClick={onPaneClick}
167
251
  onDragOver={onDragOver}
168
252
  onDrop={onDrop}
253
+ onInit={(instance) => {
254
+ flowRef.current = instance
255
+ }}
169
256
  fitView
170
- deleteKeyCode="Delete"
257
+ fitViewOptions={{ padding: 0.24 }}
258
+ nodesDraggable={!readOnly}
259
+ nodesConnectable={!readOnly}
260
+ edgesReconnectable={!readOnly}
261
+ deleteKeyCode={readOnly ? null : 'Delete'}
171
262
  defaultEdgeOptions={{ type: 'default', data: { edgeType: 'default' } }}
172
263
  >
173
264
  <Background />
@@ -175,7 +266,7 @@ export function ProtocolBuilderCanvas() {
175
266
  <MiniMap />
176
267
  </ReactFlow>
177
268
  </div>
178
- <div className="flex w-72 flex-col gap-3">
269
+ <div className="flex w-72 shrink-0 flex-col gap-3">
179
270
  <NodeInspector />
180
271
  <ValidationPanel />
181
272
  </div>
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ import { ThemeProvider as NextThemeProvider } from 'next-themes'
4
+
5
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
6
+ return (
7
+ <NextThemeProvider
8
+ attribute="class"
9
+ defaultTheme="dark"
10
+ enableSystem
11
+ disableTransitionOnChange
12
+ >
13
+ {children}
14
+ </NextThemeProvider>
15
+ )
16
+ }
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import { useProtocolBuilderStore } from '../protocol-builder-store'
3
3
  import { useUpsertProtocolTemplateMutation, type ProtocolTemplatePayload } from '@/features/protocols/queries'
4
4
  import { nodesToTemplate } from '../utils/nodes-to-template'
5
+ import { isBuilderTemplateReadOnly } from '../utils/builder-template-access'
5
6
 
6
7
  export function useTemplateSync(autoSaveDelayMs = 2000) {
7
8
  const nodes = useProtocolBuilderStore((s) => s.nodes)
@@ -15,6 +16,10 @@ export function useTemplateSync(autoSaveDelayMs = 2000) {
15
16
 
16
17
  useEffect(() => {
17
18
  if (!isDirty || !currentTemplate || validationErrors.length > 0) return
19
+ if (isBuilderTemplateReadOnly(currentTemplate)) {
20
+ if (debounceRef.current) clearTimeout(debounceRef.current)
21
+ return
22
+ }
18
23
 
19
24
  if (debounceRef.current) clearTimeout(debounceRef.current)
20
25
 
@@ -67,8 +67,8 @@ export interface ProtocolBuilderState {
67
67
  redoStack: UndoSnapshot[]
68
68
  activeRunId: string | null
69
69
 
70
- setNodes: (nodes: BuilderNode[]) => void
71
- setEdges: (edges: BuilderEdge[]) => void
70
+ setNodes: (nodes: BuilderNode[], options?: { markDirty?: boolean }) => void
71
+ setEdges: (edges: BuilderEdge[], options?: { markDirty?: boolean }) => void
72
72
  selectNode: (nodeId: string | null) => void
73
73
  selectEdge: (edgeId: string | null) => void
74
74
  updateNodeData: (nodeId: string, data: Partial<BuilderNodeData>) => void
@@ -110,8 +110,8 @@ export const useProtocolBuilderStore = create<ProtocolBuilderState>()(
110
110
  (set, get) => ({
111
111
  ...initialState,
112
112
 
113
- setNodes: (nodes) => set({ nodes, isDirty: true }),
114
- setEdges: (edges) => set({ edges, isDirty: true }),
113
+ setNodes: (nodes, options) => set({ nodes, isDirty: options?.markDirty ?? true }),
114
+ setEdges: (edges, options) => set({ edges, isDirty: options?.markDirty ?? true }),
115
115
 
116
116
  selectNode: (nodeId) =>
117
117
  set({ selectedNodeId: nodeId, selectedEdgeId: null }),
@@ -0,0 +1,30 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { ProtocolTemplate } from '@/types'
4
+ import { isBuilderTemplateReadOnly } from './builder-template-access'
5
+
6
+ function makeTemplate(overrides: Partial<ProtocolTemplate> = {}): ProtocolTemplate {
7
+ return {
8
+ id: 'template-1',
9
+ name: 'Template One',
10
+ description: 'A template',
11
+ builtIn: false,
12
+ defaultPhases: [],
13
+ ...overrides,
14
+ }
15
+ }
16
+
17
+ describe('isBuilderTemplateReadOnly', () => {
18
+ it('marks built-in templates as read-only', () => {
19
+ assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: true })), true)
20
+ })
21
+
22
+ it('keeps custom templates editable', () => {
23
+ assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: false })), false)
24
+ })
25
+
26
+ it('treats an unloaded template as non-editable only after one is present', () => {
27
+ assert.equal(isBuilderTemplateReadOnly(null), false)
28
+ assert.equal(isBuilderTemplateReadOnly(undefined), false)
29
+ })
30
+ })
@@ -0,0 +1,5 @@
1
+ import type { ProtocolTemplate } from '@/types'
2
+
3
+ export function isBuilderTemplateReadOnly(template: ProtocolTemplate | null | undefined): boolean {
4
+ return Boolean(template?.builtIn)
5
+ }
@@ -68,3 +68,125 @@ test('appendMessage notifies both generic and per-session message topics', () =>
68
68
  assert.deepEqual(output.genericTopics, ['messages'])
69
69
  assert.deepEqual(output.sessionTopics, ['messages:sess-notify'])
70
70
  })
71
+
72
+ test('lazy migration compacts legacy session message blobs after table persistence', () => {
73
+ const output = runWithTempDataDir<{
74
+ returnedTexts: string[]
75
+ secondReadTexts: string[]
76
+ blobMessageCount: number
77
+ messageCount: number
78
+ lastMessageText: string | null
79
+ }>(`
80
+ const storageMod = await import('@/lib/server/storage')
81
+ const repoMod = await import('@/lib/server/messages/message-repository')
82
+ const storage = storageMod.default || storageMod
83
+ const repo = repoMod.default || repoMod
84
+
85
+ storage.saveSessions({
86
+ 'sess-legacy-blob': {
87
+ id: 'sess-legacy-blob',
88
+ name: 'Legacy blob session',
89
+ cwd: process.env.WORKSPACE_DIR,
90
+ user: 'tester',
91
+ provider: 'openai',
92
+ model: 'gpt-5',
93
+ claudeSessionId: null,
94
+ codexThreadId: null,
95
+ opencodeSessionId: null,
96
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
97
+ messages: [
98
+ { role: 'user', text: 'first legacy prompt', time: 1 },
99
+ { role: 'assistant', text: 'first legacy reply', time: 2 },
100
+ { role: 'user', text: 'second legacy prompt', time: 3 },
101
+ ],
102
+ createdAt: Date.now(),
103
+ lastActiveAt: Date.now(),
104
+ },
105
+ })
106
+
107
+ const returned = repo.getMessages('sess-legacy-blob')
108
+ const secondRead = repo.getMessages('sess-legacy-blob')
109
+ const stored = storage.loadSessions()['sess-legacy-blob']
110
+
111
+ console.log(JSON.stringify({
112
+ returnedTexts: returned.map((message) => message.text),
113
+ secondReadTexts: secondRead.map((message) => message.text),
114
+ blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
115
+ messageCount: stored.messageCount,
116
+ lastMessageText: stored.lastMessageSummary?.text || null,
117
+ }))
118
+ `, { prefix: 'swarmclaw-message-repo-compact-' })
119
+
120
+ assert.deepEqual(output.returnedTexts, [
121
+ 'first legacy prompt',
122
+ 'first legacy reply',
123
+ 'second legacy prompt',
124
+ ])
125
+ assert.deepEqual(output.secondReadTexts, output.returnedTexts)
126
+ assert.equal(output.blobMessageCount, 0)
127
+ assert.equal(output.messageCount, 3)
128
+ assert.equal(output.lastMessageText, 'second legacy prompt')
129
+ })
130
+
131
+ test('bulk migration reports compaction for table-backed legacy blobs', () => {
132
+ const output = runWithTempDataDir<{
133
+ result: {
134
+ migrated: number
135
+ compacted: number
136
+ skipped: number
137
+ total: number
138
+ }
139
+ blobMessageCount: number
140
+ messageCount: number
141
+ }>(`
142
+ const storageMod = await import('@/lib/server/storage')
143
+ const repoMod = await import('@/lib/server/messages/message-repository')
144
+ const storage = storageMod.default || storageMod
145
+ const repo = repoMod.default || repoMod
146
+
147
+ const messages = [
148
+ { role: 'user', text: 'stale blob prompt', time: 10 },
149
+ { role: 'assistant', text: 'stale blob reply', time: 20 },
150
+ ]
151
+
152
+ storage.saveSessions({
153
+ 'sess-table-backed-blob': {
154
+ id: 'sess-table-backed-blob',
155
+ name: 'Table backed blob session',
156
+ cwd: process.env.WORKSPACE_DIR,
157
+ user: 'tester',
158
+ provider: 'openai',
159
+ model: 'gpt-5',
160
+ claudeSessionId: null,
161
+ codexThreadId: null,
162
+ opencodeSessionId: null,
163
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
164
+ messages,
165
+ createdAt: Date.now(),
166
+ lastActiveAt: Date.now(),
167
+ },
168
+ })
169
+
170
+ const db = storage.getDb()
171
+ const insert = db.prepare('INSERT INTO session_messages (session_id, seq, data) VALUES (?, ?, ?)')
172
+ messages.forEach((message, index) => {
173
+ insert.run('sess-table-backed-blob', index, JSON.stringify(message))
174
+ })
175
+
176
+ const result = repo.migrateAllSessions()
177
+ const stored = storage.loadSessions()['sess-table-backed-blob']
178
+
179
+ console.log(JSON.stringify({
180
+ result,
181
+ blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
182
+ messageCount: stored.messageCount,
183
+ }))
184
+ `, { prefix: 'swarmclaw-message-repo-migrate-report-' })
185
+
186
+ assert.equal(output.result.migrated, 0)
187
+ assert.equal(output.result.compacted, 1)
188
+ assert.equal(output.result.skipped, 1)
189
+ assert.equal(output.result.total, 1)
190
+ assert.equal(output.blobMessageCount, 0)
191
+ assert.equal(output.messageCount, 2)
192
+ })
@@ -86,6 +86,38 @@ function summarizeForMeta(message: Message): Message {
86
86
  }
87
87
  }
88
88
 
89
+ function getLastAssistantAt(messages: Message[]): number | null {
90
+ for (let i = messages.length - 1; i >= 0; i--) {
91
+ if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
92
+ return messages[i].time
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ function compactDeprecatedBlobMessages(
99
+ sessionId: string,
100
+ persistedCount: number,
101
+ lastMsg: Message | null,
102
+ lastAssistantAt: number | null,
103
+ ): boolean {
104
+ let compacted = false
105
+ patchSession(sessionId, (current) => {
106
+ if (!current) return null
107
+ const blobCount = Array.isArray(current.messages) ? current.messages.length : 0
108
+ if (blobCount === 0) return current
109
+ if (persistedCount < blobCount) return current
110
+
111
+ current.messages = []
112
+ current.messageCount = persistedCount
113
+ current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
114
+ if (lastAssistantAt !== null) current.lastAssistantAt = lastAssistantAt
115
+ compacted = true
116
+ return current
117
+ })
118
+ return compacted
119
+ }
120
+
89
121
  // ---------------------------------------------------------------------------
90
122
  // Session metadata sync — keeps messageCount / lastMessageSummary on the blob
91
123
  // ---------------------------------------------------------------------------
@@ -139,18 +171,14 @@ function lazyMigrateSession(sessionId: string): Message[] | null {
139
171
  ins.run(sessionId, i, JSON.stringify(messages[i]))
140
172
  }
141
173
 
142
- // Compute metadata on the blob (keep messages intact for backward compat)
174
+ // Compute metadata before compacting deprecated blob storage.
143
175
  const lastMsg = messages[messages.length - 1]
144
- let lastAssistantAt: number | null = null
145
- for (let i = messages.length - 1; i >= 0; i--) {
146
- if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
147
- lastAssistantAt = messages[i].time
148
- break
149
- }
150
- }
176
+ const lastAssistantAt = getLastAssistantAt(messages)
177
+ const persistedCount = rowCount(sessionId)
151
178
 
152
179
  patchSession(sessionId, (current) => {
153
180
  if (!current) return null
181
+ current.messages = persistedCount >= messages.length ? [] : current.messages
154
182
  current.messageCount = messages.length
155
183
  current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
156
184
  if (lastAssistantAt !== null && typeof current.lastAssistantAt !== 'number') {
@@ -183,6 +211,7 @@ export function getMessages(sessionId: string): Message[] {
183
211
  const m = parseMsg(row.data)
184
212
  if (m) out.push(m)
185
213
  }
214
+ compactDeprecatedBlobMessages(sessionId, out.length, out[out.length - 1] || null, getLastAssistantAt(out))
186
215
  return out
187
216
  }, { sessionId })
188
217
  }
@@ -338,10 +367,16 @@ export function deleteSessionMessages(sessionId: string): void {
338
367
  // Bulk migration (for CLI / admin endpoint)
339
368
  // ---------------------------------------------------------------------------
340
369
 
341
- export function migrateAllSessions(): { migrated: number; skipped: number; total: number } {
370
+ export function migrateAllSessions(): {
371
+ migrated: number
372
+ compacted: number
373
+ skipped: number
374
+ total: number
375
+ } {
342
376
  const db = getDb()
343
377
  const rows = db.prepare('SELECT id, data FROM sessions').all() as Array<{ id: string; data: string }>
344
378
  let migrated = 0
379
+ let compacted = 0
345
380
  let skipped = 0
346
381
 
347
382
  for (const row of rows) {
@@ -351,16 +386,37 @@ export function migrateAllSessions(): { migrated: number; skipped: number; total
351
386
  skipped++
352
387
  continue
353
388
  }
354
- if (rowCount(row.id) > 0) {
389
+
390
+ const persistedCount = rowCount(row.id)
391
+ if (persistedCount > 0) {
392
+ const persistedRows = stmts().selectAll.all(row.id) as Array<{ data: string }>
393
+ const persistedMessages: Message[] = []
394
+ for (const persistedRow of persistedRows) {
395
+ const message = parseMsg(persistedRow.data)
396
+ if (message) persistedMessages.push(message)
397
+ }
398
+ if (compactDeprecatedBlobMessages(
399
+ row.id,
400
+ persistedCount,
401
+ persistedMessages[persistedMessages.length - 1] || null,
402
+ getLastAssistantAt(persistedMessages),
403
+ )) {
404
+ compacted++
405
+ }
355
406
  skipped++
356
407
  continue
357
408
  }
409
+
358
410
  lazyMigrateSession(row.id)
359
411
  migrated++
412
+ const stored = loadSession(row.id)
413
+ if (!Array.isArray(stored?.messages) || stored.messages.length === 0) {
414
+ compacted++
415
+ }
360
416
  } catch {
361
417
  skipped++
362
418
  }
363
419
  }
364
420
 
365
- return { migrated, skipped, total: rows.length }
421
+ return { migrated, compacted, skipped, total: rows.length }
366
422
  }
@@ -23,10 +23,10 @@ const NEXT_CONFIG_FILES = [
23
23
  ]
24
24
 
25
25
  function readPackageJson(dir: string): PackageJsonLike | null {
26
- const pkgPath = path.join(dir, 'package.json')
27
- if (!fs.existsSync(pkgPath)) return null
26
+ const pkgPath = path.join(/*turbopackIgnore: true*/ dir, 'package.json')
27
+ if (!fs.existsSync(/*turbopackIgnore: true*/ pkgPath)) return null
28
28
  try {
29
- const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
29
+ const parsed: unknown = JSON.parse(fs.readFileSync(/*turbopackIgnore: true*/ pkgPath, 'utf8'))
30
30
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
31
31
  ? parsed as PackageJsonLike
32
32
  : null
@@ -46,7 +46,10 @@ function hasNextScript(pkg: PackageJsonLike): boolean {
46
46
  }
47
47
 
48
48
  function hasNextConfig(dir: string): boolean {
49
- return NEXT_CONFIG_FILES.some((file) => fs.existsSync(path.join(dir, file)))
49
+ return NEXT_CONFIG_FILES.some((file) => {
50
+ const configPath = path.join(/*turbopackIgnore: true*/ dir, file)
51
+ return fs.existsSync(/*turbopackIgnore: true*/ configPath)
52
+ })
50
53
  }
51
54
 
52
55
  function classifyPackageRoot(dir: string, pkg: PackageJsonLike): FrameworkKind {
@@ -0,0 +1,5 @@
1
+ export type ThemeMode = 'light' | 'dark' | 'system'
2
+
3
+ export function normalizeThemeMode(value: unknown): ThemeMode {
4
+ return value === 'light' || value === 'system' ? value : 'dark'
5
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SessionResetMode } from './session'
2
2
  import type { ExtensionManagedLocalFolderDeclaration } from './extension'
3
+ import type { ThemeMode } from '@/lib/theme-mode'
3
4
 
4
5
  // --- App Settings ---
5
6
  export type LoopMode = 'bounded' | 'ongoing'
@@ -134,6 +135,7 @@ export interface AppSettings {
134
135
  defaultAgentId?: string | null
135
136
  // Theme
136
137
  themeHue?: string
138
+ themeMode?: ThemeMode
137
139
  // Web search provider
138
140
  webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave' | 'exa'
139
141
  searxngUrl?: string