jettypod 4.4.120 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,6 +1,6 @@
1
- 'use client';
2
1
 
3
2
  import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from 'react';
3
+ import { invoke, invokeWithTimeout } from '../lib/tauri';
4
4
  import {
5
5
  type ClaudeMessage,
6
6
  type StreamStatus,
@@ -40,6 +40,7 @@ export interface Session {
40
40
  error: string | null;
41
41
  exitCode: number | null;
42
42
  narratedMode: boolean;
43
+ fullReadoutMode: boolean;
43
44
  }
44
45
 
45
46
  // Split into 3 contexts to prevent unnecessary re-renders:
@@ -60,6 +61,8 @@ interface SessionStateContextValue {
60
61
  canRetry: boolean;
61
62
  queuedMessage: QueuedMessage | null;
62
63
  narratedMode: boolean;
64
+ fullReadoutMode: boolean;
65
+ rawEvents: unknown[];
63
66
  isTabSwitching: boolean;
64
67
  }
65
68
 
@@ -67,17 +70,21 @@ interface SessionActionsContextValue {
67
70
  setClaudePanelOpen: (open: boolean) => void;
68
71
  openSession: (id: string, title: string, type?: string, conversational?: boolean, description?: string | null, initialHidden?: boolean) => void;
69
72
  switchSession: (id: string) => void;
70
- closeSession: (sessionId: string) => void;
73
+ closeSession: (sessionId: string) => Promise<void>;
71
74
  openSessionPanel: () => void;
72
75
  createNewSession: () => Promise<void>;
73
76
  createAddToBacklogSession: () => Promise<void>;
74
77
  createRunScenarioSession: (featureFile: string, scenarioTitle: string) => Promise<void>;
75
78
  createFixScenarioSession: (featureFile: string, scenarioTitle: string, error: string, failedStep?: string, steps?: string[]) => Promise<void>;
79
+ createFixServiceSession: (crashedServices: { name: string; port: number | null }[]) => Promise<void>;
80
+ createBddSetupSession: (setupPrompt: string) => Promise<void>;
76
81
  createWelcomeSession: () => Promise<void>;
82
+ createSkillSession: (skillName: string, title: string, customMessage?: string) => Promise<string>;
77
83
  sendMessage: (message: string, images?: AttachedImage[]) => void;
78
84
  retry: () => void;
79
85
  stop: () => void;
80
86
  toggleNarratedMode: () => void;
87
+ toggleFullReadout: () => void;
81
88
  }
82
89
 
83
90
  interface SessionPersistenceContextValue {
@@ -164,29 +171,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
164
171
  sessionsRef.current = sessions;
165
172
 
166
173
  // Wrapper to update both React state and sync ref
167
- // Also pins/unpins sessions to prevent idle process cleanup
174
+ // Pin/unpin removed no longer needed in Tauri (no idle process cleanup)
168
175
  const setActiveSessionId = useCallback((id: string | null) => {
169
- const previousId = sessionRefs.activeSessionId.current;
170
- const currentSessions = sessionsRef.current;
171
-
172
- // Unpin previous session (allow idle cleanup)
173
- if (previousId && previousId !== id) {
174
- const session = currentSessions.get(previousId);
175
- const endpoint = session?.type === 'workitem'
176
- ? `/api/claude/${previousId}/pin`
177
- : `/api/claude/sessions/${previousId}/pin`;
178
- fetch(endpoint, { method: 'DELETE' }).catch(() => {});
179
- }
180
-
181
- // Pin new session (prevent idle cleanup)
182
- if (id) {
183
- const session = currentSessions.get(id);
184
- const endpoint = session?.type === 'workitem'
185
- ? `/api/claude/${id}/pin`
186
- : `/api/claude/sessions/${id}/pin`;
187
- fetch(endpoint, { method: 'POST' }).catch(() => {});
188
- }
189
-
190
176
  sessionRefs.activeSessionId.set(id);
191
177
  setActiveSessionIdState(id);
192
178
  }, [sessionRefs]);
@@ -194,18 +180,47 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
194
180
  // Tab-switching UX refs (NOT stream ownership - each session owns its own stream)
195
181
  // Trailing-edge debounce for rapid session switches (#1000100)
196
182
  const switchDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
197
- // AbortController for cancelling in-flight historical content fetches (#1000101)
198
- const contentFetchAbortRef = useRef<AbortController | null>(null);
199
-
200
183
  // Reactive tab-switching state (exposed to UI so components can suppress flash of empty state)
201
184
  const [isTabSwitching, setIsTabSwitching] = useState(false);
202
185
 
203
- // Force re-render when stream state changes (since stream managers are mutable)
204
- const [, forceUpdate] = useState({});
205
-
206
186
  // Standalone sessions state
207
187
  const [standaloneSessions, setStandaloneSessions] = useState<SessionItem[]>([]);
208
188
 
189
+ // Cache: work item ID → DB session ID (avoids N+1 db_get_sessions_for_work_item calls)
190
+ const dbSessionIdCache = useRef(new Map<string, number>());
191
+
192
+ // Persist session messages to DB with proper error handling
193
+ const persistSessionMessages = useCallback(async (sessionId: string, messages: ClaudeMessage[]) => {
194
+ if (messages.length === 0) return;
195
+ const session = sessionsRef.current.get(sessionId);
196
+ try {
197
+ if (session?.type === 'standalone') {
198
+ await invoke('db_set_session_content', {
199
+ id: Number(sessionId),
200
+ content: JSON.stringify(messages),
201
+ });
202
+ } else {
203
+ // Work-item session: check cache first, then look up
204
+ let dbId = dbSessionIdCache.current.get(sessionId);
205
+ if (!dbId) {
206
+ const linked = await invoke<any[]>('db_get_sessions_for_work_item', { workItemId: Number(sessionId) });
207
+ if (linked?.[0]?.id) {
208
+ dbId = linked[0].id;
209
+ dbSessionIdCache.current.set(sessionId, dbId);
210
+ }
211
+ }
212
+ if (dbId) {
213
+ await invoke('db_set_session_content', {
214
+ id: dbId,
215
+ content: JSON.stringify(messages),
216
+ });
217
+ }
218
+ }
219
+ } catch (err) {
220
+ console.error('Failed to persist session messages:', sessionId, err);
221
+ }
222
+ }, []);
223
+
209
224
  // Subscribe to registry events to sync React state with registry
210
225
  useEffect(() => {
211
226
  const handleStateChange = (sessionId: string, state: StreamState) => {
@@ -221,6 +236,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
221
236
  machine.send('CONNECTED');
222
237
  } else if (state.status === 'done') {
223
238
  machine.send('COMPLETE');
239
+ // Persist messages to DB so they survive page refresh
240
+ persistSessionMessages(sessionId, state.messages);
224
241
  // Auto-send queued message after stream completes
225
242
  if (state.queuedMessage) {
226
243
  const streamManager = registry.get(sessionId);
@@ -234,6 +251,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
234
251
  }
235
252
  } else if (state.status === 'error') {
236
253
  machine.send('ERROR');
254
+ // Persist messages on error too so user can see what happened after refresh
255
+ persistSessionMessages(sessionId, state.messages);
237
256
  } else if (state.status === 'idle' && machine.state !== 'idle') {
238
257
  // Force to idle if we got out of sync
239
258
  machine.forceState('idle');
@@ -248,23 +267,34 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
248
267
  }
249
268
  }
250
269
 
251
- // Update React state
252
- setSessions(prev => {
253
- const session = prev.get(sessionId);
254
- if (!session) return prev;
270
+ // Update React state (skip if nothing changed to avoid unnecessary re-renders)
271
+ // Check BEFORE calling setSessions to avoid queueing a no-op state update
272
+ const currentSession = sessionsRef.current.get(sessionId);
273
+ if (!currentSession) return;
274
+ if (
275
+ currentSession.messages === state.messages &&
276
+ currentSession.status === state.status &&
277
+ currentSession.error === state.error &&
278
+ currentSession.exitCode === state.exitCode &&
279
+ currentSession.narratedMode === state.narratedMode &&
280
+ currentSession.fullReadoutMode === state.fullReadoutMode
281
+ ) {
282
+ return;
283
+ }
255
284
 
285
+ setSessions(prev => {
256
286
  const updated = new Map(prev);
257
287
  updated.set(sessionId, {
258
- ...session,
288
+ ...currentSession,
259
289
  messages: state.messages,
260
290
  status: state.status,
261
291
  error: state.error,
262
292
  exitCode: state.exitCode,
263
293
  narratedMode: state.narratedMode,
294
+ fullReadoutMode: state.fullReadoutMode,
264
295
  });
265
296
  return updated;
266
297
  });
267
- forceUpdate({});
268
298
  };
269
299
 
270
300
  registry.on('stateChange', handleStateChange);
@@ -272,8 +302,9 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
272
302
 
273
303
  return () => {
274
304
  registry.off('stateChange', handleStateChange);
305
+ registry.stopCleanup();
275
306
  };
276
- }, [registry, sessionRefs, getStateMachine, messageBuffer]);
307
+ }, [registry, sessionRefs, getStateMachine, messageBuffer, persistSessionMessages]);
277
308
 
278
309
  // Persist active session ID to sessionStorage
279
310
  useEffect(() => {
@@ -292,7 +323,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
292
323
  const getOrCreateStreamManager = useCallback((
293
324
  sessionId: string,
294
325
  standalone: boolean,
295
- onWorkItemCreated?: (workItemId: number, title: string) => void,
326
+ onWorkItemCreated?: (workItemId: number, title: string, sourceSessionId: string) => void,
296
327
  conversational?: boolean
297
328
  ) => {
298
329
  // Registry handles idempotent creation and state change events
@@ -315,154 +346,206 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
315
346
  if (!sourceSessionId) return;
316
347
 
317
348
  try {
318
- const response = await fetch('/api/claude/sessions', {
319
- method: 'PATCH',
320
- headers: { 'Content-Type': 'application/json' },
321
- body: JSON.stringify({
322
- sessionId: parseInt(sourceSessionId, 10),
323
- workItemId,
324
- }),
349
+ await invoke('db_link_claude_session', {
350
+ sessionId: parseInt(sourceSessionId, 10),
351
+ workItemId: Number(workItemId),
352
+ title,
325
353
  });
354
+ } catch (err) {
355
+ // DB link failed — don't re-key registry or state will be inconsistent
356
+ console.error('Failed to link session in DB:', sourceSessionId, '->', workItemId, err);
357
+ return;
358
+ }
326
359
 
327
- if (response.ok || response.status === 409) {
328
- const newSessionId = String(workItemId);
360
+ try {
361
+ const newSessionId = String(workItemId);
329
362
 
330
- // Update stream manager's session context via registry
331
- const streamManager = registry.get(sourceSessionId);
332
- if (streamManager) {
333
- streamManager.updateSessionContext({
334
- workItemId: newSessionId,
335
- standalone: false,
336
- });
337
- }
363
+ // Update stream manager's session context via registry
364
+ const streamManager = registry.get(sourceSessionId);
365
+ if (streamManager) {
366
+ streamManager.updateSessionContext({
367
+ workItemId: newSessionId,
368
+ standalone: false,
369
+ });
370
+ }
338
371
 
339
- // Atomic session update (#1000102): Build new map in one pass
340
- setSessions(prev => {
341
- const session = prev.get(sourceSessionId);
342
- if (!session) return prev;
372
+ // Re-key the registry entry so the onStateChange closure emits
373
+ // with the new session ID (fixes stream freeze after linking #1115)
374
+ registry.rekey(sourceSessionId, newSessionId);
343
375
 
344
- // Build new map: copy all except old session, add new session
345
- const updated = new Map<string, Session>();
346
- for (const [key, value] of prev) {
347
- if (key !== sourceSessionId) {
348
- updated.set(key, value);
349
- }
376
+ // Atomic session update (#1000102): Build new map in one pass
377
+ setSessions(prev => {
378
+ const session = prev.get(sourceSessionId);
379
+ if (!session) return prev;
380
+
381
+ // Build new map: copy all except old session, add new session
382
+ const updated = new Map<string, Session>();
383
+ for (const [key, value] of prev) {
384
+ if (key !== sourceSessionId) {
385
+ updated.set(key, value);
350
386
  }
351
- updated.set(newSessionId, {
352
- ...session,
353
- id: newSessionId,
354
- type: 'workitem',
355
- title,
356
- });
357
- return updated;
387
+ }
388
+ updated.set(newSessionId, {
389
+ ...session,
390
+ id: newSessionId,
391
+ type: 'workitem',
392
+ title,
358
393
  });
394
+ return updated;
395
+ });
359
396
 
360
- // Update active session ID to the work item ID
361
- setActiveSessionId(newSessionId);
397
+ // Update active session ID to the work item ID
398
+ setActiveSessionId(newSessionId);
362
399
 
363
- // Remove from standalone sessions list
364
- setStandaloneSessions(prev =>
365
- prev.filter(s => s.id !== sourceSessionId)
366
- );
367
- }
400
+ // Remove from standalone sessions list
401
+ setStandaloneSessions(prev =>
402
+ prev.filter(s => s.id !== sourceSessionId)
403
+ );
368
404
  } catch (err) {
369
- console.error('Failed to link session to work item:', err);
405
+ console.error('Failed to re-key session after link:', err);
370
406
  }
371
407
  }, [registry, setActiveSessionId, refreshUsage]);
372
408
 
373
- // Load persisted sessions from backend on mount
374
- useEffect(() => {
375
- async function loadSessions() {
409
+ // Track whether sessions have been loaded successfully
410
+ const sessionsLoadedRef = useRef(false);
411
+ // Promise deduplication: if loadSessions is already in flight, return the existing promise
412
+ const loadSessionsPromiseRef = useRef<Promise<void> | null>(null);
413
+
414
+ // Load persisted sessions from backend.
415
+ // Retries silently when DB isn't initialized yet (e.g., no project open).
416
+ // Deduplicates concurrent calls (e.g., Strict Mode double-mount).
417
+ const loadSessions = useCallback(async () => {
418
+ if (loadSessionsPromiseRef.current) {
419
+ return loadSessionsPromiseRef.current;
420
+ }
421
+ const doLoad = async () => {
376
422
  try {
377
- const response = await fetch('/api/claude/sessions');
378
- if (!response.ok) return;
423
+ const persistedSessions = await invokeWithTimeout<any[]>('db_get_all_sessions', undefined, 10000);
424
+ if (!Array.isArray(persistedSessions)) return;
425
+
426
+ sessionsLoadedRef.current = true;
427
+
428
+ const workItemSessions: typeof persistedSessions = [];
429
+ const standaloneItems: SessionItem[] = [];
430
+ // Map session DB id → parsed messages for content restoration
431
+ const contentMap = new Map<string, ClaudeMessage[]>();
432
+
433
+ for (const session of persistedSessions) {
434
+ if (!session) continue;
435
+
436
+ // Parse persisted content if available
437
+ if (session.content) {
438
+ try {
439
+ const parsed = JSON.parse(session.content);
440
+ if (Array.isArray(parsed) && parsed.length > 0) {
441
+ const key = session.work_item_id ? String(session.work_item_id) : String(session.id);
442
+ contentMap.set(key, parsed);
443
+ }
444
+ } catch { /* ignore malformed content */ }
445
+ }
379
446
 
380
- const persistedSessions = await response.json();
381
- if (!Array.isArray(persistedSessions)) return;
447
+ if (session.work_item_id) {
448
+ if (session.title) {
449
+ workItemSessions.push(session);
450
+ }
451
+ } else {
452
+ standaloneItems.push({
453
+ id: String(session.id),
454
+ title: session.session_title || session.title || 'Untitled Session',
455
+ featureId: null,
456
+ featureTitle: null,
457
+ updatedAt: session.completed_at || session.started_at,
458
+ });
459
+ }
460
+ }
382
461
 
383
- const workItemSessions: typeof persistedSessions = [];
384
- const standaloneItems: SessionItem[] = [];
462
+ // Add all sessions (work item + standalone) to sessions Map for state management
463
+ setSessions(prev => {
464
+ const updated = new Map(prev);
385
465
 
386
- for (const session of persistedSessions) {
387
- if (!session) continue;
466
+ // Add work item sessions (stream manager created lazily on first use)
467
+ for (const session of workItemSessions) {
468
+ const sessionId = String(session.work_item_id);
469
+ if (updated.has(sessionId)) continue;
388
470
 
389
- if (session.featureId) {
390
- if (session.title) {
391
- workItemSessions.push(session);
392
- }
393
- } else {
394
- standaloneItems.push({
395
- id: session.id, // API returns string IDs
396
- title: session.session_title || session.title || 'Untitled Session',
397
- featureId: null,
398
- featureTitle: null,
399
- updatedAt: session.updatedAt,
400
- });
401
- }
402
- }
471
+ const status = session.status;
472
+ const frontendStatus = status === 'completed' ? 'done'
473
+ : status === 'error' ? 'error'
474
+ : 'idle';
475
+ const restoredMessages = contentMap.get(sessionId) ?? [];
403
476
 
404
- // Add all sessions (work item + standalone) to sessions Map for state management
405
- setSessions(prev => {
406
- const updated = new Map(prev);
407
-
408
- // Add work item sessions (stream manager created lazily on first use)
409
- for (const session of workItemSessions) {
410
- const sessionId = session.featureId; // API returns string IDs
411
- if (updated.has(sessionId)) continue;
412
-
413
- const status = session.status;
414
- const frontendStatus = status === 'completed' ? 'done'
415
- : status === 'error' ? 'error'
416
- : 'idle';
417
-
418
- updated.set(sessionId, {
419
- id: sessionId,
420
- title: session.title,
421
- type: 'workitem',
422
- messages: [],
423
- status: frontendStatus as StreamStatus,
424
- error: null,
425
- exitCode: status === 'completed' ? 0 : status === 'error' ? 1 : null,
426
- narratedMode: true,
427
- // Stream manager created lazily when session becomes active
428
- });
429
- }
477
+ updated.set(sessionId, {
478
+ id: sessionId,
479
+ title: session.title,
480
+ type: 'workitem',
481
+ messages: restoredMessages,
482
+ status: restoredMessages.length > 0 ? 'done' as StreamStatus : frontendStatus as StreamStatus,
483
+ error: null,
484
+ exitCode: status === 'completed' ? 0 : status === 'error' ? 1 : null,
485
+ narratedMode: true,
486
+ fullReadoutMode: false,
487
+ });
488
+ }
430
489
 
431
- // Add standalone sessions (stream manager created lazily on first use)
432
- for (const session of standaloneItems) {
433
- if (updated.has(session.id)) continue;
434
-
435
- updated.set(session.id, {
436
- id: session.id,
437
- title: session.title,
438
- type: 'standalone',
439
- messages: [],
440
- status: 'idle',
441
- error: null,
442
- exitCode: null,
443
- // Welcome session shows static content — detail view shows all messages
444
- narratedMode: session.title !== 'Welcome',
445
- // Stream manager created lazily when session becomes active
446
- });
447
- }
490
+ // Add standalone sessions (stream manager created lazily on first use)
491
+ for (const session of standaloneItems) {
492
+ if (updated.has(session.id)) continue;
493
+ const restoredMessages = contentMap.get(session.id) ?? [];
494
+
495
+ updated.set(session.id, {
496
+ id: session.id,
497
+ title: session.title,
498
+ type: 'standalone',
499
+ messages: restoredMessages,
500
+ status: restoredMessages.length > 0 ? 'done' as StreamStatus : 'idle',
501
+ error: null,
502
+ exitCode: null,
503
+ narratedMode: session.title !== 'Welcome',
504
+ fullReadoutMode: false,
505
+ });
506
+ }
448
507
 
449
- return updated;
450
- });
508
+ return updated;
509
+ });
451
510
 
452
- setStandaloneSessions(standaloneItems);
453
- } catch (err) {
454
- console.error('[ClaudeSessionContext] Failed to load sessions:', err);
511
+ setStandaloneSessions(standaloneItems);
512
+
513
+ // Auto-select active session if none is set (prevents "no active session" on sendMessage)
514
+ if (!sessionRefs.activeSessionId.current) {
515
+ const allSessionIds = [
516
+ ...workItemSessions.map(s => String(s.work_item_id)),
517
+ ...standaloneItems.map(s => s.id),
518
+ ];
519
+ const savedId = sessionStorage.getItem(ACTIVE_SESSION_KEY);
520
+ if (savedId && allSessionIds.includes(savedId)) {
521
+ setActiveSessionId(savedId);
522
+ } else if (allSessionIds.length > 0) {
523
+ setActiveSessionId(allSessionIds[0]);
524
+ }
455
525
  }
526
+ } catch {
527
+ // Silently ignore — DB may not be initialized yet (no project open).
528
+ // Sessions will be loaded when openSessionPanel triggers loadSessions.
456
529
  }
530
+ };
531
+ loadSessionsPromiseRef.current = doLoad();
532
+ try {
533
+ await loadSessionsPromiseRef.current;
534
+ } finally {
535
+ loadSessionsPromiseRef.current = null;
536
+ }
537
+ }, [sessionRefs, setActiveSessionId]);
538
+
539
+ // Load sessions on mount (may fail if no project is open yet — that's OK)
540
+ useEffect(() => {
457
541
  loadSessions();
458
- }, []);
542
+ }, [loadSessions]);
459
543
 
460
544
  // Helper: Ensure session has a stream manager in registry (lazy creation)
461
545
  const ensureStreamManager = useCallback((sessionId: string, session: Session) => {
462
546
  // Check registry first (idempotent - returns existing if present)
463
547
  const existing = registry.get(sessionId);
464
548
  if (existing) {
465
- registry.acquire(sessionId); // Track reference for lifecycle
466
549
  return existing;
467
550
  }
468
551
 
@@ -472,7 +555,6 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
472
555
  session.type === 'standalone',
473
556
  session.type === 'standalone' ? handleWorkItemCreated : undefined
474
557
  );
475
- registry.acquire(sessionId); // Track reference for lifecycle
476
558
 
477
559
  return streamManager;
478
560
  }, [registry, getOrCreateStreamManager, handleWorkItemCreated]);
@@ -498,7 +580,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
498
580
 
499
581
  // Create stream manager in registry — pass conversational flag for skip-delay behavior
500
582
  const streamManager = getOrCreateStreamManager(id, false, undefined, conversational);
501
- registry.acquire(id);
583
+ registry.recordActivity(id);
502
584
 
503
585
  const newSession: Session = {
504
586
  id,
@@ -509,6 +591,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
509
591
  error: null,
510
592
  exitCode: null,
511
593
  narratedMode: true,
594
+ fullReadoutMode: false,
512
595
  };
513
596
 
514
597
  setSessions(prev => {
@@ -553,128 +636,115 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
553
636
  messageBuffer.startBuffering(previousSessionId);
554
637
  }
555
638
 
556
- // Trailing-edge debounce for rapid switches (#1000100)
639
+ // Cancel any in-flight switch so rapid clicks don't cause stale loads
557
640
  if (switchDebounceTimeoutRef.current) {
558
641
  clearTimeout(switchDebounceTimeoutRef.current);
642
+ switchDebounceTimeoutRef.current = null;
559
643
  }
560
644
 
561
645
  // Immediately update visual state (active tab) for responsiveness
562
646
  switchingToRef.current = id;
563
647
  setActiveSessionId(id);
564
648
 
565
- // Debounce the heavy lifting (content loading)
566
- switchDebounceTimeoutRef.current = setTimeout(async () => {
567
- switchDebounceTimeoutRef.current = null;
649
+ // Load content immediately (no debounce — local SQLite IPC is fast)
650
+ const session = sessions.get(id)!;
651
+ const isStandalone = session.type === 'standalone';
568
652
 
569
- // Check if target changed during debounce
570
- if (switchingToRef.current !== id) return;
571
- if (!sessions.has(id)) return;
653
+ // Ensure session has a stream manager in registry
654
+ const streamManager = ensureStreamManager(id, session);
572
655
 
573
- const session = sessions.get(id)!;
574
- const isStandalone = session.type === 'standalone';
575
-
576
- // Ensure session has a stream manager in registry
577
- const streamManager = ensureStreamManager(id, session);
578
-
579
- // Use sync ref to check streaming status (avoids stale closure)
580
- const isActivelyStreaming = isSessionStreaming(sessionRefs, id) ||
581
- session.status === 'streaming' ||
582
- streamManager.status === 'streaming';
583
- const hasMessages = session.messages.length > 0 || streamManager.messages.length > 0;
584
-
585
- // Only load from DB if session is idle with no messages
586
- if (!isActivelyStreaming && !hasMessages) {
587
- const queryParam = isStandalone ? '' : '?by=workitem';
588
- try {
589
- // Cancel any in-flight content fetch (#1000101)
590
- if (contentFetchAbortRef.current) {
591
- contentFetchAbortRef.current.abort();
592
- }
593
- contentFetchAbortRef.current = new AbortController();
656
+ // Use sync ref to check streaming status (avoids stale closure)
657
+ const isActivelyStreaming = isSessionStreaming(sessionRefs, id) ||
658
+ session.status === 'streaming' ||
659
+ streamManager.status === 'streaming';
660
+ const hasMessages = session.messages.length > 0 || streamManager.messages.length > 0;
594
661
 
595
- const response = await fetch(`/api/claude/sessions/${id}/content${queryParam}`, {
596
- signal: contentFetchAbortRef.current.signal
597
- });
598
-
599
- // Check if user switched to a different session while we were fetching
600
- if (switchingToRef.current !== id) return;
662
+ // Only load from DB if session is idle with no messages
663
+ if (!isActivelyStreaming && !hasMessages) {
664
+ try {
665
+ // Look up the session's DB ID: for standalone sessions use the id directly,
666
+ // for work-item sessions look up sessions linked to that work item
667
+ let content: any[] | null = null;
668
+
669
+ if (isStandalone) {
670
+ const sessionData = await invoke<any>('db_get_session', { id: Number(id) });
671
+ if (sessionData?.content) {
672
+ try { content = JSON.parse(sessionData.content); } catch { content = null; }
673
+ }
674
+ } else {
675
+ const linkedSessions = await invoke<any[]>('db_get_sessions_for_work_item', { workItemId: Number(id) });
676
+ if (linkedSessions && linkedSessions.length > 0 && linkedSessions[0].content) {
677
+ try { content = JSON.parse(linkedSessions[0].content); } catch { content = null; }
678
+ }
679
+ }
601
680
 
602
- if (response.ok) {
603
- const { content } = await response.json();
681
+ // Check if user switched to a different session while we were fetching
682
+ if (switchingToRef.current !== id) return;
604
683
 
605
- // Double-check after parsing response
606
- if (switchingToRef.current !== id) return;
684
+ // Re-check streaming status using sync ref (most current)
685
+ const currentlyStreaming = isSessionStreaming(sessionRefs, id);
686
+ const currentSession = sessions.get(id);
687
+ const currentManager = registry.get(id);
688
+ const nowHasMessages = (currentSession?.messages.length ?? 0) > 0 ||
689
+ (currentManager?.messages.length ?? 0) > 0;
607
690
 
608
- // Re-check streaming status using sync ref (most current)
609
- const currentlyStreaming = isSessionStreaming(sessionRefs, id);
610
- const currentSession = sessions.get(id);
611
- const currentManager = registry.get(id);
612
- const nowHasMessages = (currentSession?.messages.length ?? 0) > 0 ||
613
- (currentManager?.messages.length ?? 0) > 0;
691
+ if (currentlyStreaming || nowHasMessages) {
692
+ // Session became active during fetch - don't overwrite
693
+ return;
694
+ }
614
695
 
615
- if (currentlyStreaming || nowHasMessages) {
616
- // Session became active during fetch - don't overwrite
696
+ if (content && Array.isArray(content) && content.length > 0) {
697
+ // Update stream manager via registry
698
+ const mgr = registry.get(id);
699
+ if (mgr) {
700
+ // Final safety check via sync ref
701
+ if (isSessionStreaming(sessionRefs, id)) {
617
702
  return;
618
703
  }
704
+ mgr.setMessages(content);
705
+ mgr.setStatus('done');
706
+ }
619
707
 
620
- if (content && Array.isArray(content) && content.length > 0) {
621
- // Update stream manager via registry
622
- const mgr = registry.get(id);
623
- if (mgr) {
624
- // Final safety check via sync ref
625
- if (isSessionStreaming(sessionRefs, id)) {
626
- return;
627
- }
628
- mgr.setMessages(content);
629
- mgr.setStatus('done');
630
- }
631
-
632
- // Update session React state
633
- setSessions(prev => {
634
- const updated = new Map(prev);
635
- const existing = updated.get(id);
636
- if (existing) {
637
- updated.set(id, { ...existing, messages: content, status: 'done' });
638
- }
639
- return updated;
640
- });
708
+ // Update session React state
709
+ setSessions(prev => {
710
+ const updated = new Map(prev);
711
+ const existing = updated.get(id);
712
+ if (existing) {
713
+ updated.set(id, { ...existing, messages: content!, status: 'done' });
641
714
  }
642
- }
643
- } catch (err) {
644
- // Ignore abort errors - they're expected when user switches tabs (#1000101)
645
- if (err instanceof Error && err.name === 'AbortError') {
646
- return;
647
- }
648
- console.error('[ClaudeSessionContext] Failed to load session content:', err);
715
+ return updated;
716
+ });
649
717
  }
718
+ } catch (err) {
719
+ console.error('[ClaudeSessionContext] Failed to load session content:', err);
650
720
  }
721
+ }
651
722
 
652
- // Flush buffered messages for the previous session
653
- if (previousSessionId && previousSessionId !== id) {
654
- const bufferedMessages = messageBuffer.flushBuffer(previousSessionId);
655
- if (bufferedMessages.length > 0) {
656
- const prevManager = registry.get(previousSessionId);
657
- if (prevManager) {
658
- // Apply buffered messages to the stream manager
659
- const existingMessages = prevManager.messages;
660
- prevManager.setMessages([...existingMessages, ...bufferedMessages]);
661
- }
723
+ // Flush buffered messages for the previous session
724
+ if (previousSessionId && previousSessionId !== id) {
725
+ const bufferedMessages = messageBuffer.flushBuffer(previousSessionId);
726
+ if (bufferedMessages.length > 0) {
727
+ const prevManager = registry.get(previousSessionId);
728
+ if (prevManager) {
729
+ // Apply buffered messages to the stream manager
730
+ const existingMessages = prevManager.messages;
731
+ prevManager.setMessages([...existingMessages, ...bufferedMessages]);
662
732
  }
663
- messageBuffer.stopBuffering(previousSessionId);
664
733
  }
734
+ messageBuffer.stopBuffering(previousSessionId);
735
+ }
665
736
 
666
- // Clear tracking ref when switch completes
667
- if (switchingToRef.current === id) {
668
- switchingToRef.current = null;
669
- }
670
- sessionRefs.isTabSwitching.set(false);
671
- setIsTabSwitching(false);
672
- }, 100); // 100ms debounce delay
737
+ // Clear tracking ref when switch completes
738
+ if (switchingToRef.current === id) {
739
+ switchingToRef.current = null;
740
+ }
741
+ sessionRefs.isTabSwitching.set(false);
742
+ setIsTabSwitching(false);
673
743
  }, [sessions, sessionRefs, registry, ensureStreamManager, setActiveSessionId, messageBuffer]);
674
744
 
675
745
  // Close a session
676
746
  // With per-session streams, we delete the stream manager from registry when closing
677
- const closeSession = useCallback((sessionId: string) => {
747
+ const closeSession = useCallback(async (sessionId: string) => {
678
748
  // Get session to determine API type
679
749
  const session = sessions.get(sessionId);
680
750
  const sessionType = session?.type || 'standalone';
@@ -685,7 +755,14 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
685
755
  // Clear streaming status in sync ref
686
756
  setSessionStreaming(sessionRefs, sessionId, false);
687
757
 
688
- // Update UI state immediately (before any async work)
758
+ // Persist close to DB first prevents sessions reappearing after refresh
759
+ try {
760
+ await invokeWithTimeout('db_close_claude_session', { id: Number(sessionId) }, 5000);
761
+ } catch (err) {
762
+ console.error('Failed to close session in DB:', sessionId, err);
763
+ }
764
+
765
+ // Update UI state after DB persistence
689
766
  setStandaloneSessions(prev => prev.filter(s => s.id !== sessionId));
690
767
  setSessions(prev => {
691
768
  const updated = new Map(prev);
@@ -709,17 +786,6 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
709
786
  setActiveSessionId(remaining[Math.min(newIndex, remaining.length - 1)]);
710
787
  }
711
788
  }
712
-
713
- // API cleanup in background (unpin + delete) — UI already updated above
714
- const endpoint = sessionType === 'workitem'
715
- ? `/api/claude/${sessionId}/pin`
716
- : `/api/claude/sessions/${sessionId}/pin`;
717
- fetch(endpoint, { method: 'DELETE' }).catch(() => {});
718
- fetch(`/api/claude/sessions?sessionId=${sessionId}&type=${sessionType}`, {
719
- method: 'DELETE',
720
- }).catch(err => {
721
- console.error('[ClaudeSessionContext] Failed to close session:', err);
722
- });
723
789
  }, [activeSessionId, sessions, standaloneSessions, registry, sessionRefs, setActiveSessionId]);
724
790
 
725
791
  // Create a new standalone session
@@ -730,25 +796,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
730
796
  }
731
797
 
732
798
  try {
733
- const response = await fetch('/api/claude/sessions', {
734
- method: 'POST',
735
- headers: { 'Content-Type': 'application/json' },
736
- body: JSON.stringify({ title: 'New Session' }),
799
+ const sessionId = await invoke<number>('db_create_claude_session', {
800
+ title: 'New Session',
801
+ sessionTitle: null,
737
802
  });
738
-
739
- if (!response.ok) {
740
- const errorData = await response.json();
741
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
742
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
743
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
744
- } else {
745
- console.error('[ClaudeSessionContext] Failed to create session:', errorData.error);
746
- }
747
- return;
748
- }
749
-
750
- const { id, title } = await response.json();
751
- // API returns string IDs
803
+ const id = String(sessionId);
804
+ const title = 'New Session';
752
805
 
753
806
  setStandaloneSessions(prev => [...prev, {
754
807
  id,
@@ -759,7 +812,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
759
812
 
760
813
  // Create stream manager in registry
761
814
  getOrCreateStreamManager(id, true, handleWorkItemCreated);
762
- registry.acquire(id);
815
+ registry.recordActivity(id);
763
816
 
764
817
  const newSession: Session = {
765
818
  id,
@@ -770,6 +823,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
770
823
  error: null,
771
824
  exitCode: null,
772
825
  narratedMode: true,
826
+ fullReadoutMode: false,
773
827
  };
774
828
 
775
829
  setSessions(prev => {
@@ -789,7 +843,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
789
843
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, usageAllowed, refreshUsage]);
790
844
 
791
845
  // Open the session panel (restore last session or create new)
792
- const openSessionPanel = useCallback(() => {
846
+ const openSessionPanel = useCallback(async () => {
847
+ // If sessions haven't loaded yet (DB wasn't ready on mount), retry now
848
+ if (!sessionsLoadedRef.current) {
849
+ await loadSessions();
850
+ }
851
+
793
852
  const savedSessionId = sessionStorage.getItem(ACTIVE_SESSION_KEY);
794
853
 
795
854
  if (savedSessionId && sessions.has(savedSessionId)) {
@@ -807,10 +866,10 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
807
866
 
808
867
  // No sessions exist - create new standalone session (unless over usage limit)
809
868
  if (usageAllowed) {
810
- createNewSession();
869
+ await createNewSession();
811
870
  }
812
871
  setClaudePanelOpen(true);
813
- }, [sessions, switchSession, createNewSession, usageAllowed]);
872
+ }, [sessions, switchSession, createNewSession, usageAllowed, loadSessions]);
814
873
 
815
874
  // Send message via the active session's stream manager
816
875
  // With per-session streams, each session has its own manager - no cross-contamination possible
@@ -926,6 +985,31 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
926
985
  });
927
986
  }, [sessionRefs, registry]);
928
987
 
988
+ // Toggle full readout mode (raw stream-json events) for active session
989
+ const toggleFullReadout = useCallback(() => {
990
+ const currentActiveId = sessionRefs.activeSessionId.current;
991
+ if (!currentActiveId) return;
992
+
993
+ setSessions(prev => {
994
+ const session = prev.get(currentActiveId);
995
+ if (!session) return prev;
996
+
997
+ const newFullReadout = !session.fullReadoutMode;
998
+
999
+ const streamManager = registry.get(currentActiveId);
1000
+ if (streamManager) {
1001
+ streamManager.setFullReadoutModeQuiet(newFullReadout);
1002
+ }
1003
+
1004
+ const updated = new Map(prev);
1005
+ updated.set(currentActiveId, {
1006
+ ...session,
1007
+ fullReadoutMode: newFullReadout,
1008
+ });
1009
+ return updated;
1010
+ });
1011
+ }, [sessionRefs, registry]);
1012
+
929
1013
  // Create an "Add to Backlog" session with initial assistant message
930
1014
  // With per-session streams, each session has its own manager in registry
931
1015
  const createAddToBacklogSession = useCallback(async () => {
@@ -934,25 +1018,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
934
1018
  }
935
1019
 
936
1020
  try {
937
- const response = await fetch('/api/claude/sessions', {
938
- method: 'POST',
939
- headers: { 'Content-Type': 'application/json' },
940
- body: JSON.stringify({ title: 'Add to Backlog' }),
1021
+ const sessionId = await invoke<number>('db_create_claude_session', {
1022
+ title: 'Add to Backlog',
1023
+ sessionTitle: null,
941
1024
  });
942
-
943
- if (!response.ok) {
944
- const errorData = await response.json();
945
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
946
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
947
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
948
- } else {
949
- console.error('[ClaudeSessionContext] Failed to create backlog session:', errorData.error);
950
- }
951
- return;
952
- }
953
-
954
- const { id, title } = await response.json();
955
- // API returns string IDs
1025
+ const id = String(sessionId);
1026
+ const title = 'Add to Backlog';
956
1027
 
957
1028
  setStandaloneSessions(prev => [...prev, {
958
1029
  id,
@@ -970,7 +1041,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
970
1041
 
971
1042
  // Create stream manager in registry
972
1043
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
973
- registry.acquire(id);
1044
+ registry.recordActivity(id);
974
1045
  // Initialize with the welcome message
975
1046
  streamManager.setMessages([initialMessage]);
976
1047
 
@@ -983,6 +1054,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
983
1054
  error: null,
984
1055
  exitCode: null,
985
1056
  narratedMode: true,
1057
+ fullReadoutMode: false,
986
1058
  };
987
1059
 
988
1060
  setSessions(prev => {
@@ -1004,22 +1076,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1004
1076
  // Create a welcome session for blank projects with initial assistant message
1005
1077
  const createWelcomeSession = useCallback(async () => {
1006
1078
  try {
1007
- const response = await fetch('/api/claude/sessions', {
1008
- method: 'POST',
1009
- headers: { 'Content-Type': 'application/json' },
1010
- body: JSON.stringify({ title: 'Welcome' }),
1079
+ const sessionId = await invoke<number>('db_create_claude_session', {
1080
+ title: 'Welcome',
1081
+ sessionTitle: null,
1011
1082
  });
1012
-
1013
- if (!response.ok) {
1014
- const errorData = await response.json();
1015
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
1016
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
1017
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
1018
- }
1019
- return;
1020
- }
1021
-
1022
- const { id, title } = await response.json();
1083
+ const id = String(sessionId);
1084
+ const title = 'Welcome';
1023
1085
 
1024
1086
  setStandaloneSessions(prev => [...prev, {
1025
1087
  id,
@@ -1069,7 +1131,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1069
1131
  const welcomeMessages = [greetingMessage, backlogTip, workflowTip, ctaMessage];
1070
1132
 
1071
1133
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1072
- registry.acquire(id);
1134
+ registry.recordActivity(id);
1073
1135
  streamManager.setMessages(welcomeMessages);
1074
1136
 
1075
1137
  const newSession: Session = {
@@ -1081,6 +1143,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1081
1143
  error: null,
1082
1144
  exitCode: null,
1083
1145
  narratedMode: false,
1146
+ fullReadoutMode: false,
1084
1147
  };
1085
1148
 
1086
1149
  setSessions(prev => {
@@ -1096,6 +1159,70 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1096
1159
  }
1097
1160
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId]);
1098
1161
 
1162
+ // Create a session that invokes a skill by name
1163
+ const createSkillSession = useCallback(async (skillName: string, title: string, customMessage?: string): Promise<string> => {
1164
+ if (!usageAllowed) {
1165
+ return '';
1166
+ }
1167
+
1168
+ try {
1169
+ const sessionId = await invoke<number>('db_create_claude_session', {
1170
+ title,
1171
+ sessionTitle: null,
1172
+ });
1173
+ const id = String(sessionId);
1174
+
1175
+ setStandaloneSessions(prev => [...prev, {
1176
+ id,
1177
+ title,
1178
+ featureId: null,
1179
+ featureTitle: null,
1180
+ }]);
1181
+
1182
+ const userMessage: ClaudeMessage = {
1183
+ type: 'user',
1184
+ content: customMessage ?? `/${skillName} Go`,
1185
+ timestamp: Date.now(),
1186
+ };
1187
+
1188
+ const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1189
+ registry.recordActivity(id);
1190
+ streamManager.setMessages([userMessage]);
1191
+
1192
+ const newSession: Session = {
1193
+ id,
1194
+ title,
1195
+ type: 'standalone',
1196
+ messages: [userMessage],
1197
+ status: 'idle',
1198
+ error: null,
1199
+ exitCode: null,
1200
+ narratedMode: true,
1201
+ fullReadoutMode: false,
1202
+ };
1203
+
1204
+ setSessions(prev => {
1205
+ const updated = new Map(prev);
1206
+ updated.set(id, newSession);
1207
+ return updated;
1208
+ });
1209
+
1210
+ setActiveSessionId(id);
1211
+ setClaudePanelOpen(true);
1212
+
1213
+ const machine = getStateMachine(id);
1214
+ machine.send('SEND');
1215
+ streamManager.sendMessage(userMessage.content!);
1216
+
1217
+ refreshUsage();
1218
+ return id;
1219
+ } catch (err) {
1220
+ console.error('[ClaudeSessionContext] Failed to create skill session:', err);
1221
+ showToast('Failed to create session. Please try again.', 'error');
1222
+ return '';
1223
+ }
1224
+ }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, getStateMachine, usageAllowed, refreshUsage]);
1225
+
1099
1226
  // Create a "Run Scenario" session with preloaded cucumber-js command
1100
1227
  const createRunScenarioSession = useCallback(async (featureFile: string, scenarioTitle: string) => {
1101
1228
  if (!usageAllowed) {
@@ -1104,25 +1231,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1104
1231
 
1105
1232
  try {
1106
1233
  const sessionTitle = `Run: ${scenarioTitle}`;
1107
- const response = await fetch('/api/claude/sessions', {
1108
- method: 'POST',
1109
- headers: { 'Content-Type': 'application/json' },
1110
- body: JSON.stringify({ title: sessionTitle }),
1234
+ const sessionId = await invoke<number>('db_create_claude_session', {
1235
+ title: sessionTitle,
1236
+ sessionTitle: null,
1111
1237
  });
1112
-
1113
- if (!response.ok) {
1114
- const errorData = await response.json();
1115
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
1116
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
1117
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
1118
- } else {
1119
- console.error('[ClaudeSessionContext] Failed to create run scenario session:', errorData.error);
1120
- showToast('Failed to create session. Please try again.', 'error');
1121
- }
1122
- return;
1123
- }
1124
-
1125
- const { id, title } = await response.json();
1238
+ const id = String(sessionId);
1239
+ const title = sessionTitle;
1126
1240
 
1127
1241
  setStandaloneSessions(prev => [...prev, {
1128
1242
  id,
@@ -1138,7 +1252,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1138
1252
  };
1139
1253
 
1140
1254
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1141
- registry.acquire(id);
1255
+ registry.recordActivity(id);
1142
1256
  streamManager.setMessages([userMessage]);
1143
1257
 
1144
1258
  const newSession: Session = {
@@ -1150,6 +1264,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1150
1264
  error: null,
1151
1265
  exitCode: null,
1152
1266
  narratedMode: true,
1267
+ fullReadoutMode: false,
1153
1268
  };
1154
1269
 
1155
1270
  setSessions(prev => {
@@ -1182,25 +1297,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1182
1297
 
1183
1298
  try {
1184
1299
  const sessionTitle = `Fix: ${scenarioTitle}`;
1185
- const response = await fetch('/api/claude/sessions', {
1186
- method: 'POST',
1187
- headers: { 'Content-Type': 'application/json' },
1188
- body: JSON.stringify({ title: sessionTitle }),
1300
+ const sessionId = await invoke<number>('db_create_claude_session', {
1301
+ title: sessionTitle,
1302
+ sessionTitle: null,
1189
1303
  });
1190
-
1191
- if (!response.ok) {
1192
- const errorData = await response.json();
1193
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
1194
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
1195
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
1196
- } else {
1197
- console.error('[ClaudeSessionContext] Failed to create fix scenario session:', errorData.error);
1198
- showToast('Failed to create session. Please try again.', 'error');
1199
- }
1200
- return;
1201
- }
1202
-
1203
- const { id, title } = await response.json();
1304
+ const id = String(sessionId);
1305
+ const title = sessionTitle;
1204
1306
 
1205
1307
  setStandaloneSessions(prev => [...prev, {
1206
1308
  id,
@@ -1236,7 +1338,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1236
1338
  };
1237
1339
 
1238
1340
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1239
- registry.acquire(id);
1341
+ registry.recordActivity(id);
1240
1342
  streamManager.setMessages([userMessage]);
1241
1343
 
1242
1344
  const newSession: Session = {
@@ -1248,6 +1350,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1248
1350
  error: null,
1249
1351
  exitCode: null,
1250
1352
  narratedMode: true,
1353
+ fullReadoutMode: false,
1251
1354
  };
1252
1355
 
1253
1356
  setSessions(prev => {
@@ -1272,6 +1375,150 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1272
1375
  }
1273
1376
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, getStateMachine, usageAllowed, refreshUsage]);
1274
1377
 
1378
+ // Create a "Fix Service" session with context about crashed/degraded services
1379
+ const createFixServiceSession = useCallback(async (crashedServices: { name: string; port: number | null }[]) => {
1380
+ if (!usageAllowed) {
1381
+ return;
1382
+ }
1383
+
1384
+ try {
1385
+ const serviceNames = crashedServices.map(s => s.name).join(', ');
1386
+ const sessionTitle = `Fix: Degraded Services`;
1387
+ const sessionId = await invoke<number>('db_create_claude_session', {
1388
+ title: sessionTitle,
1389
+ sessionTitle: null,
1390
+ });
1391
+ const id = String(sessionId);
1392
+ const title = sessionTitle;
1393
+
1394
+ setStandaloneSessions(prev => [...prev, {
1395
+ id,
1396
+ title,
1397
+ featureId: null,
1398
+ featureTitle: null,
1399
+ }]);
1400
+
1401
+ const promptParts = [
1402
+ 'Services are degraded on the QA environment and need investigation.',
1403
+ '',
1404
+ `**Crashed services:** ${serviceNames}`,
1405
+ '',
1406
+ ];
1407
+
1408
+ for (const svc of crashedServices) {
1409
+ promptParts.push(`- **${svc.name}** (port ${svc.port ?? 'unknown'}): crashed`);
1410
+ }
1411
+
1412
+ promptParts.push('', 'Please investigate why these services crashed and fix the issues.');
1413
+
1414
+ const userMessage: ClaudeMessage = {
1415
+ type: 'user',
1416
+ content: promptParts.join('\n'),
1417
+ timestamp: Date.now(),
1418
+ };
1419
+
1420
+ const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1421
+ registry.recordActivity(id);
1422
+ streamManager.setMessages([userMessage]);
1423
+
1424
+ const newSession: Session = {
1425
+ id,
1426
+ title,
1427
+ type: 'standalone',
1428
+ messages: [userMessage],
1429
+ status: 'idle',
1430
+ error: null,
1431
+ exitCode: null,
1432
+ narratedMode: true,
1433
+ fullReadoutMode: false,
1434
+ };
1435
+
1436
+ setSessions(prev => {
1437
+ const updated = new Map(prev);
1438
+ updated.set(id, newSession);
1439
+ return updated;
1440
+ });
1441
+
1442
+ setActiveSessionId(id);
1443
+ setClaudePanelOpen(true);
1444
+
1445
+ // Auto-send the fix request to Claude
1446
+ const machine = getStateMachine(id);
1447
+ machine.send('SEND');
1448
+ streamManager.sendMessage(userMessage.content!);
1449
+
1450
+ // Refresh usage so UI reflects the new session immediately
1451
+ refreshUsage();
1452
+ } catch (err) {
1453
+ console.error('[ClaudeSessionContext] Failed to create fix service session:', err);
1454
+ showToast('Failed to create session. Please try again.', 'error');
1455
+ }
1456
+ }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, getStateMachine, usageAllowed, refreshUsage]);
1457
+
1458
+ // Create a "Set up BDD tests" session with the preflight setup prompt
1459
+ const createBddSetupSession = useCallback(async (setupPrompt: string) => {
1460
+ if (!usageAllowed) {
1461
+ return;
1462
+ }
1463
+
1464
+ try {
1465
+ const sessionTitle = 'Set up BDD tests';
1466
+ const sessionId = await invoke<number>('db_create_claude_session', {
1467
+ title: sessionTitle,
1468
+ sessionTitle: null,
1469
+ });
1470
+ const id = String(sessionId);
1471
+ const title = sessionTitle;
1472
+
1473
+ setStandaloneSessions(prev => [...prev, {
1474
+ id,
1475
+ title,
1476
+ featureId: null,
1477
+ featureTitle: null,
1478
+ }]);
1479
+
1480
+ const userMessage: ClaudeMessage = {
1481
+ type: 'user',
1482
+ content: setupPrompt,
1483
+ timestamp: Date.now(),
1484
+ };
1485
+
1486
+ const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1487
+ registry.recordActivity(id);
1488
+ streamManager.setMessages([userMessage]);
1489
+
1490
+ const newSession: Session = {
1491
+ id,
1492
+ title,
1493
+ type: 'standalone',
1494
+ messages: [userMessage],
1495
+ status: 'idle',
1496
+ error: null,
1497
+ exitCode: null,
1498
+ narratedMode: true,
1499
+ fullReadoutMode: false,
1500
+ };
1501
+
1502
+ setSessions(prev => {
1503
+ const updated = new Map(prev);
1504
+ updated.set(id, newSession);
1505
+ return updated;
1506
+ });
1507
+
1508
+ setActiveSessionId(id);
1509
+ setClaudePanelOpen(true);
1510
+
1511
+ const machine = getStateMachine(id);
1512
+ machine.send('SEND');
1513
+ streamManager.sendMessage(userMessage.content!);
1514
+
1515
+ refreshUsage();
1516
+ } catch (err) {
1517
+ console.error('[ClaudeSessionContext] Failed to create BDD setup session:', err);
1518
+ showToast('Failed to create session. Please try again.', 'error');
1519
+ }
1520
+ }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, getStateMachine, usageAllowed, refreshUsage]);
1521
+
1275
1522
  // Setters for direct manipulation (e.g., restoring from DB)
1276
1523
  // These now work through the registry
1277
1524
  const setMessages = useCallback((messages: ClaudeMessage[]) => {
@@ -1312,6 +1559,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1312
1559
  canRetry: activeStreamManager?.canRetry ?? false,
1313
1560
  queuedMessage: activeStreamManager?.queuedMessage ?? null,
1314
1561
  narratedMode: activeSession?.narratedMode ?? false,
1562
+ fullReadoutMode: activeSession?.fullReadoutMode ?? false,
1563
+ rawEvents: activeStreamManager?.rawEvents ?? [],
1315
1564
  isTabSwitching,
1316
1565
  }), [claudePanelOpen, sessions, activeSessionId, activeSession, standaloneSessions, activeStreamManager, isTabSwitching]);
1317
1566
 
@@ -1326,12 +1575,16 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1326
1575
  createAddToBacklogSession,
1327
1576
  createRunScenarioSession,
1328
1577
  createFixScenarioSession,
1578
+ createFixServiceSession,
1579
+ createBddSetupSession,
1329
1580
  createWelcomeSession,
1581
+ createSkillSession,
1330
1582
  sendMessage,
1331
1583
  retry,
1332
1584
  stop,
1333
1585
  toggleNarratedMode,
1334
- }), [setClaudePanelOpen, openSession, switchSession, closeSession, openSessionPanel, createNewSession, createAddToBacklogSession, createRunScenarioSession, createFixScenarioSession, createWelcomeSession, sendMessage, retry, stop, toggleNarratedMode]);
1586
+ toggleFullReadout,
1587
+ }), [setClaudePanelOpen, openSession, switchSession, closeSession, openSessionPanel, createNewSession, createAddToBacklogSession, createRunScenarioSession, createFixScenarioSession, createFixServiceSession, createBddSetupSession, createWelcomeSession, createSkillSession, sendMessage, retry, stop, toggleNarratedMode, toggleFullReadout]);
1335
1588
 
1336
1589
  // Memoize persistence context — stable setters
1337
1590
  const persistenceValue: SessionPersistenceContextValue = useMemo(() => ({