jettypod 4.4.118 → 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 (240) hide show
  1. package/.env +4 -3
  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 +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  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 +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  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 +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,12 +1,13 @@
1
- 'use client';
2
1
 
3
- import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
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,
7
7
  type StreamState,
8
8
  type QueuedMessage,
9
9
  } from '../lib/session-stream-manager';
10
+ import type { AttachedImage } from '../components/ClaudePanelInput';
10
11
  import { getRegistry } from '../lib/stream-manager-registry';
11
12
  import {
12
13
  createSessionRefs,
@@ -39,22 +40,20 @@ export interface Session {
39
40
  error: string | null;
40
41
  exitCode: number | null;
41
42
  narratedMode: boolean;
43
+ fullReadoutMode: boolean;
42
44
  }
43
45
 
44
- interface ClaudeSessionContextValue {
45
- // Panel state
46
- claudePanelOpen: boolean;
47
- setClaudePanelOpen: (open: boolean) => void;
46
+ // Split into 3 contexts to prevent unnecessary re-renders:
47
+ // - SessionStateContext: frequently-changing state (messages, status, sessions)
48
+ // - SessionActionsContext: stable callbacks (sendMessage, switchSession, etc.)
49
+ // - SessionPersistenceContext: internal mutation setters (setSessions, etc.)
48
50
 
49
- // Session state
51
+ interface SessionStateContextValue {
52
+ claudePanelOpen: boolean;
50
53
  sessions: Map<string, Session>;
51
54
  activeSessionId: string | null;
52
55
  activeSession: Session | null;
53
-
54
- // Standalone sessions
55
56
  standaloneSessions: SessionItem[];
56
-
57
- // Stream state (from active session's stream manager)
58
57
  messages: ClaudeMessage[];
59
58
  status: StreamStatus;
60
59
  error: string | null;
@@ -62,44 +61,71 @@ interface ClaudeSessionContextValue {
62
61
  canRetry: boolean;
63
62
  queuedMessage: QueuedMessage | null;
64
63
  narratedMode: boolean;
65
- toggleNarratedMode: () => void;
64
+ fullReadoutMode: boolean;
65
+ rawEvents: unknown[];
66
+ isTabSwitching: boolean;
67
+ }
66
68
 
67
- // Session actions
68
- openSession: (id: string, title: string, type?: string, conversational?: boolean, description?: string | null) => void;
69
+ interface SessionActionsContextValue {
70
+ setClaudePanelOpen: (open: boolean) => void;
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>;
77
-
78
- // Stream actions
79
- sendMessage: (message: string) => void;
82
+ createSkillSession: (skillName: string, title: string, customMessage?: string) => Promise<string>;
83
+ sendMessage: (message: string, images?: AttachedImage[]) => void;
80
84
  retry: () => void;
81
85
  stop: () => void;
86
+ toggleNarratedMode: () => void;
87
+ toggleFullReadout: () => void;
88
+ }
82
89
 
83
- // For components that need to set messages directly (e.g., restoring from DB)
90
+ interface SessionPersistenceContextValue {
84
91
  setMessages: (messages: ClaudeMessage[]) => void;
85
92
  setStatus: (status: StreamStatus) => void;
86
-
87
- // For RealTimeKanbanWrapper to update sessions
88
93
  setSessions: React.Dispatch<React.SetStateAction<Map<string, Session>>>;
89
94
  setActiveSessionId: (id: string | null) => void;
90
95
  setStandaloneSessions: React.Dispatch<React.SetStateAction<SessionItem[]>>;
91
96
  }
92
97
 
93
- const ClaudeSessionContext = createContext<ClaudeSessionContextValue | null>(null);
98
+ const SessionStateContext = createContext<SessionStateContextValue | null>(null);
99
+ const SessionActionsContext = createContext<SessionActionsContextValue | null>(null);
100
+ const SessionPersistenceContext = createContext<SessionPersistenceContextValue | null>(null);
94
101
 
95
- export function useClaudeSession() {
96
- const context = useContext(ClaudeSessionContext);
97
- if (!context) {
98
- throw new Error('useClaudeSession must be used within a ClaudeSessionProvider');
99
- }
102
+ // Granular hooks — use these for better performance
103
+ export function useSessionState() {
104
+ const context = useContext(SessionStateContext);
105
+ if (!context) throw new Error('useSessionState must be used within a ClaudeSessionProvider');
106
+ return context;
107
+ }
108
+
109
+ export function useSessionActions() {
110
+ const context = useContext(SessionActionsContext);
111
+ if (!context) throw new Error('useSessionActions must be used within a ClaudeSessionProvider');
100
112
  return context;
101
113
  }
102
114
 
115
+ export function useSessionPersistence() {
116
+ const context = useContext(SessionPersistenceContext);
117
+ if (!context) throw new Error('useSessionPersistence must be used within a ClaudeSessionProvider');
118
+ return context;
119
+ }
120
+
121
+ // Backward-compatible hook — combines all 3 contexts
122
+ export function useClaudeSession() {
123
+ const state = useSessionState();
124
+ const actions = useSessionActions();
125
+ const persistence = useSessionPersistence();
126
+ return { ...state, ...actions, ...persistence };
127
+ }
128
+
103
129
  const ACTIVE_SESSION_KEY = 'jettypod-active-session-id';
104
130
 
105
131
  export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
@@ -145,29 +171,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
145
171
  sessionsRef.current = sessions;
146
172
 
147
173
  // Wrapper to update both React state and sync ref
148
- // Also pins/unpins sessions to prevent idle process cleanup
174
+ // Pin/unpin removed no longer needed in Tauri (no idle process cleanup)
149
175
  const setActiveSessionId = useCallback((id: string | null) => {
150
- const previousId = sessionRefs.activeSessionId.current;
151
- const currentSessions = sessionsRef.current;
152
-
153
- // Unpin previous session (allow idle cleanup)
154
- if (previousId && previousId !== id) {
155
- const session = currentSessions.get(previousId);
156
- const endpoint = session?.type === 'workitem'
157
- ? `/api/claude/${previousId}/pin`
158
- : `/api/claude/sessions/${previousId}/pin`;
159
- fetch(endpoint, { method: 'DELETE' }).catch(() => {});
160
- }
161
-
162
- // Pin new session (prevent idle cleanup)
163
- if (id) {
164
- const session = currentSessions.get(id);
165
- const endpoint = session?.type === 'workitem'
166
- ? `/api/claude/${id}/pin`
167
- : `/api/claude/sessions/${id}/pin`;
168
- fetch(endpoint, { method: 'POST' }).catch(() => {});
169
- }
170
-
171
176
  sessionRefs.activeSessionId.set(id);
172
177
  setActiveSessionIdState(id);
173
178
  }, [sessionRefs]);
@@ -175,15 +180,47 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
175
180
  // Tab-switching UX refs (NOT stream ownership - each session owns its own stream)
176
181
  // Trailing-edge debounce for rapid session switches (#1000100)
177
182
  const switchDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
178
- // AbortController for cancelling in-flight historical content fetches (#1000101)
179
- const contentFetchAbortRef = useRef<AbortController | null>(null);
180
-
181
- // Force re-render when stream state changes (since stream managers are mutable)
182
- const [, forceUpdate] = useState({});
183
+ // Reactive tab-switching state (exposed to UI so components can suppress flash of empty state)
184
+ const [isTabSwitching, setIsTabSwitching] = useState(false);
183
185
 
184
186
  // Standalone sessions state
185
187
  const [standaloneSessions, setStandaloneSessions] = useState<SessionItem[]>([]);
186
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
+
187
224
  // Subscribe to registry events to sync React state with registry
188
225
  useEffect(() => {
189
226
  const handleStateChange = (sessionId: string, state: StreamState) => {
@@ -199,6 +236,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
199
236
  machine.send('CONNECTED');
200
237
  } else if (state.status === 'done') {
201
238
  machine.send('COMPLETE');
239
+ // Persist messages to DB so they survive page refresh
240
+ persistSessionMessages(sessionId, state.messages);
202
241
  // Auto-send queued message after stream completes
203
242
  if (state.queuedMessage) {
204
243
  const streamManager = registry.get(sessionId);
@@ -212,6 +251,8 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
212
251
  }
213
252
  } else if (state.status === 'error') {
214
253
  machine.send('ERROR');
254
+ // Persist messages on error too so user can see what happened after refresh
255
+ persistSessionMessages(sessionId, state.messages);
215
256
  } else if (state.status === 'idle' && machine.state !== 'idle') {
216
257
  // Force to idle if we got out of sync
217
258
  machine.forceState('idle');
@@ -226,23 +267,34 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
226
267
  }
227
268
  }
228
269
 
229
- // Update React state
230
- setSessions(prev => {
231
- const session = prev.get(sessionId);
232
- 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
+ }
233
284
 
285
+ setSessions(prev => {
234
286
  const updated = new Map(prev);
235
287
  updated.set(sessionId, {
236
- ...session,
288
+ ...currentSession,
237
289
  messages: state.messages,
238
290
  status: state.status,
239
291
  error: state.error,
240
292
  exitCode: state.exitCode,
241
293
  narratedMode: state.narratedMode,
294
+ fullReadoutMode: state.fullReadoutMode,
242
295
  });
243
296
  return updated;
244
297
  });
245
- forceUpdate({});
246
298
  };
247
299
 
248
300
  registry.on('stateChange', handleStateChange);
@@ -250,8 +302,9 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
250
302
 
251
303
  return () => {
252
304
  registry.off('stateChange', handleStateChange);
305
+ registry.stopCleanup();
253
306
  };
254
- }, [registry, sessionRefs, getStateMachine, messageBuffer]);
307
+ }, [registry, sessionRefs, getStateMachine, messageBuffer, persistSessionMessages]);
255
308
 
256
309
  // Persist active session ID to sessionStorage
257
310
  useEffect(() => {
@@ -270,7 +323,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
270
323
  const getOrCreateStreamManager = useCallback((
271
324
  sessionId: string,
272
325
  standalone: boolean,
273
- onWorkItemCreated?: (workItemId: number, title: string) => void,
326
+ onWorkItemCreated?: (workItemId: number, title: string, sourceSessionId: string) => void,
274
327
  conversational?: boolean
275
328
  ) => {
276
329
  // Registry handles idempotent creation and state change events
@@ -284,163 +337,215 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
284
337
  // Note: The stream manager already guards on sessionContext.standalone before
285
338
  // firing this callback, so we don't need to re-check sessions here (which
286
339
  // would fail due to stale closure over the sessions Map).
287
- const handleWorkItemCreated = useCallback(async (workItemId: number, title: string) => {
340
+ const handleWorkItemCreated = useCallback(async (workItemId: number, title: string, sourceSessionId: string) => {
288
341
  // Refresh usage count from local DB (work item now exists in work.db)
289
342
  refreshUsage();
290
343
 
291
- // Use sync ref to get current active session (avoids stale closure)
292
- const currentActiveId = sessionRefs.activeSessionId.current;
293
- if (!currentActiveId) return;
344
+ // Use the source session ID passed by the stream manager — NOT activeSessionId,
345
+ // which may point to a different tab if the user switched during streaming (#1001272)
346
+ if (!sourceSessionId) return;
294
347
 
295
348
  try {
296
- const response = await fetch('/api/claude/sessions', {
297
- method: 'PATCH',
298
- headers: { 'Content-Type': 'application/json' },
299
- body: JSON.stringify({
300
- sessionId: parseInt(currentActiveId, 10),
301
- workItemId,
302
- }),
349
+ await invoke('db_link_claude_session', {
350
+ sessionId: parseInt(sourceSessionId, 10),
351
+ workItemId: Number(workItemId),
352
+ title,
303
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
+ }
304
359
 
305
- if (response.ok) {
306
- const newSessionId = String(workItemId);
360
+ try {
361
+ const newSessionId = String(workItemId);
362
+
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
+ }
307
371
 
308
- // Update stream manager's session context via registry
309
- const streamManager = registry.get(currentActiveId);
310
- if (streamManager) {
311
- streamManager.updateSessionContext({
312
- workItemId: newSessionId,
313
- standalone: false,
314
- });
315
- }
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);
316
375
 
317
- // Atomic session update (#1000102): Build new map in one pass
318
- setSessions(prev => {
319
- const session = prev.get(currentActiveId);
320
- if (!session) return prev;
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;
321
380
 
322
- // Build new map: copy all except old session, add new session
323
- const updated = new Map<string, Session>();
324
- for (const [key, value] of prev) {
325
- if (key !== currentActiveId) {
326
- updated.set(key, value);
327
- }
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);
328
386
  }
329
- updated.set(newSessionId, {
330
- ...session,
331
- id: newSessionId,
332
- type: 'workitem',
333
- title,
334
- });
335
- return updated;
387
+ }
388
+ updated.set(newSessionId, {
389
+ ...session,
390
+ id: newSessionId,
391
+ type: 'workitem',
392
+ title,
336
393
  });
394
+ return updated;
395
+ });
337
396
 
338
- // Update active session ID to the work item ID
339
- setActiveSessionId(newSessionId);
397
+ // Update active session ID to the work item ID
398
+ setActiveSessionId(newSessionId);
340
399
 
341
- // Remove from standalone sessions list
342
- setStandaloneSessions(prev =>
343
- prev.filter(s => s.id !== currentActiveId)
344
- );
345
- }
400
+ // Remove from standalone sessions list
401
+ setStandaloneSessions(prev =>
402
+ prev.filter(s => s.id !== sourceSessionId)
403
+ );
346
404
  } catch (err) {
347
- console.error('Failed to link session to work item:', err);
405
+ console.error('Failed to re-key session after link:', err);
348
406
  }
349
- }, [sessionRefs, registry, setActiveSessionId, refreshUsage]);
350
-
351
- // Load persisted sessions from backend on mount
352
- useEffect(() => {
353
- async function loadSessions() {
407
+ }, [registry, setActiveSessionId, refreshUsage]);
408
+
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 () => {
354
422
  try {
355
- const response = await fetch('/api/claude/sessions');
356
- 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
+ }
446
+
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
+ }
357
461
 
358
- const persistedSessions = await response.json();
359
- if (!Array.isArray(persistedSessions)) return;
462
+ // Add all sessions (work item + standalone) to sessions Map for state management
463
+ setSessions(prev => {
464
+ const updated = new Map(prev);
360
465
 
361
- const workItemSessions: typeof persistedSessions = [];
362
- const standaloneItems: SessionItem[] = [];
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;
363
470
 
364
- for (const session of persistedSessions) {
365
- if (!session) continue;
471
+ const status = session.status;
472
+ const frontendStatus = status === 'completed' ? 'done'
473
+ : status === 'error' ? 'error'
474
+ : 'idle';
475
+ const restoredMessages = contentMap.get(sessionId) ?? [];
366
476
 
367
- if (session.featureId) {
368
- if (session.title) {
369
- workItemSessions.push(session);
370
- }
371
- } else {
372
- standaloneItems.push({
373
- id: session.id, // API returns string IDs
374
- title: session.session_title || session.title || 'Untitled Session',
375
- featureId: null,
376
- featureTitle: null,
377
- updatedAt: session.updatedAt,
378
- });
379
- }
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
+ });
380
488
  }
381
489
 
382
- // Add all sessions (work item + standalone) to sessions Map for state management
383
- setSessions(prev => {
384
- const updated = new Map(prev);
385
-
386
- // Add work item sessions (stream manager created lazily on first use)
387
- for (const session of workItemSessions) {
388
- const sessionId = session.featureId; // API returns string IDs
389
- if (updated.has(sessionId)) continue;
390
-
391
- const status = session.status;
392
- const frontendStatus = status === 'completed' ? 'done'
393
- : status === 'error' ? 'error'
394
- : 'idle';
395
-
396
- updated.set(sessionId, {
397
- id: sessionId,
398
- title: session.title,
399
- type: 'workitem',
400
- messages: [],
401
- status: frontendStatus as StreamStatus,
402
- error: null,
403
- exitCode: status === 'completed' ? 0 : status === 'error' ? 1 : null,
404
- narratedMode: true,
405
- // Stream manager created lazily when session becomes active
406
- });
407
- }
408
-
409
- // Add standalone sessions (stream manager created lazily on first use)
410
- for (const session of standaloneItems) {
411
- if (updated.has(session.id)) continue;
412
-
413
- updated.set(session.id, {
414
- id: session.id,
415
- title: session.title,
416
- type: 'standalone',
417
- messages: [],
418
- status: 'idle',
419
- error: null,
420
- exitCode: null,
421
- // Welcome session shows static content — detail view shows all messages
422
- narratedMode: session.title !== 'Welcome',
423
- // Stream manager created lazily when session becomes active
424
- });
425
- }
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
+ }
426
507
 
427
- return updated;
428
- });
508
+ return updated;
509
+ });
429
510
 
430
- setStandaloneSessions(standaloneItems);
431
- } catch (err) {
432
- 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
+ }
433
525
  }
526
+ } catch {
527
+ // Silently ignore — DB may not be initialized yet (no project open).
528
+ // Sessions will be loaded when openSessionPanel triggers loadSessions.
434
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(() => {
435
541
  loadSessions();
436
- }, []);
542
+ }, [loadSessions]);
437
543
 
438
544
  // Helper: Ensure session has a stream manager in registry (lazy creation)
439
545
  const ensureStreamManager = useCallback((sessionId: string, session: Session) => {
440
546
  // Check registry first (idempotent - returns existing if present)
441
547
  const existing = registry.get(sessionId);
442
548
  if (existing) {
443
- registry.acquire(sessionId); // Track reference for lifecycle
444
549
  return existing;
445
550
  }
446
551
 
@@ -450,7 +555,6 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
450
555
  session.type === 'standalone',
451
556
  session.type === 'standalone' ? handleWorkItemCreated : undefined
452
557
  );
453
- registry.acquire(sessionId); // Track reference for lifecycle
454
558
 
455
559
  return streamManager;
456
560
  }, [registry, getOrCreateStreamManager, handleWorkItemCreated]);
@@ -458,7 +562,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
458
562
  // Open or create a session for a work item
459
563
  // With per-session streams, no need to stop other streams or track ownership
460
564
  // Auto-sends an initial message so Claude starts working immediately
461
- const openSession = useCallback((id: string, title: string, type?: string, conversational?: boolean, description?: string | null) => {
565
+ const openSession = useCallback((id: string, title: string, type?: string, conversational?: boolean, description?: string | null, initialHidden?: boolean) => {
462
566
  if (sessions.has(id)) {
463
567
  // Switching to existing session - ensure it has a stream manager
464
568
  const session = sessions.get(id)!;
@@ -471,13 +575,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
471
575
 
472
576
  // Gate new session creation on usage limits
473
577
  if (!usageAllowed) {
474
- showToast('Weekly limit reached. Upgrade to continue using Claude features.', 'error');
475
578
  return;
476
579
  }
477
580
 
478
581
  // Create stream manager in registry — pass conversational flag for skip-delay behavior
479
582
  const streamManager = getOrCreateStreamManager(id, false, undefined, conversational);
480
- registry.acquire(id);
583
+ registry.recordActivity(id);
481
584
 
482
585
  const newSession: Session = {
483
586
  id,
@@ -488,6 +591,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
488
591
  error: null,
489
592
  exitCode: null,
490
593
  narratedMode: true,
594
+ fullReadoutMode: false,
491
595
  };
492
596
 
493
597
  setSessions(prev => {
@@ -500,13 +604,14 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
500
604
  setClaudePanelOpen(true);
501
605
 
502
606
  // Auto-send initial message so Claude starts working immediately
503
- // Conversational chores include their description so Claude has full context
607
+ // Conversational chores: send description as hidden message so Claude speaks first
608
+ // Non-conversational: send visible "starting work" message
504
609
  const initialMessage = conversational
505
610
  ? `This is a conversation — no code, no worktrees. Just chat with me naturally.\n\n${description || title}`
506
611
  : `I'm starting work on ${type || 'work item'} #${id}: ${title}`;
507
612
  const machine = getStateMachine(id);
508
613
  machine.send('SEND');
509
- streamManager.sendMessage(initialMessage);
614
+ streamManager.sendMessage(initialMessage, undefined, (conversational || initialHidden) ? { hidden: true } : undefined);
510
615
 
511
616
  // Refresh usage so UI reflects the new session immediately
512
617
  refreshUsage();
@@ -521,8 +626,9 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
521
626
  const switchSession = useCallback(async (id: string) => {
522
627
  if (!sessions.has(id)) return;
523
628
 
524
- // Mark tab switch in progress (sync ref for callbacks)
629
+ // Mark tab switch in progress (sync ref for callbacks + reactive state for UI)
525
630
  sessionRefs.isTabSwitching.set(true);
631
+ setIsTabSwitching(true);
526
632
 
527
633
  // Start buffering messages for the previous session to prevent loss
528
634
  const previousSessionId = sessionRefs.activeSessionId.current;
@@ -530,122 +636,110 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
530
636
  messageBuffer.startBuffering(previousSessionId);
531
637
  }
532
638
 
533
- // Trailing-edge debounce for rapid switches (#1000100)
639
+ // Cancel any in-flight switch so rapid clicks don't cause stale loads
534
640
  if (switchDebounceTimeoutRef.current) {
535
641
  clearTimeout(switchDebounceTimeoutRef.current);
642
+ switchDebounceTimeoutRef.current = null;
536
643
  }
537
644
 
538
645
  // Immediately update visual state (active tab) for responsiveness
539
646
  switchingToRef.current = id;
540
647
  setActiveSessionId(id);
541
648
 
542
- // Debounce the heavy lifting (content loading)
543
- switchDebounceTimeoutRef.current = setTimeout(async () => {
544
- switchDebounceTimeoutRef.current = null;
545
-
546
- // Check if target changed during debounce
547
- if (switchingToRef.current !== id) return;
548
- if (!sessions.has(id)) return;
649
+ // Load content immediately (no debounce — local SQLite IPC is fast)
650
+ const session = sessions.get(id)!;
651
+ const isStandalone = session.type === 'standalone';
549
652
 
550
- const session = sessions.get(id)!;
551
- const isStandalone = session.type === 'standalone';
552
-
553
- // Ensure session has a stream manager in registry
554
- const streamManager = ensureStreamManager(id, session);
555
-
556
- // Use sync ref to check streaming status (avoids stale closure)
557
- const isActivelyStreaming = isSessionStreaming(sessionRefs, id) ||
558
- session.status === 'streaming' ||
559
- streamManager.status === 'streaming';
560
- const hasMessages = session.messages.length > 0 || streamManager.messages.length > 0;
561
-
562
- // Only load from DB if session is idle with no messages
563
- if (!isActivelyStreaming && !hasMessages) {
564
- const queryParam = isStandalone ? '' : '?by=workitem';
565
- try {
566
- // Cancel any in-flight content fetch (#1000101)
567
- if (contentFetchAbortRef.current) {
568
- contentFetchAbortRef.current.abort();
569
- }
570
- contentFetchAbortRef.current = new AbortController();
653
+ // Ensure session has a stream manager in registry
654
+ const streamManager = ensureStreamManager(id, session);
571
655
 
572
- const response = await fetch(`/api/claude/sessions/${id}/content${queryParam}`, {
573
- signal: contentFetchAbortRef.current.signal
574
- });
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;
575
661
 
576
- // Check if user switched to a different session while we were fetching
577
- 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
+ }
578
680
 
579
- if (response.ok) {
580
- const { content } = await response.json();
681
+ // Check if user switched to a different session while we were fetching
682
+ if (switchingToRef.current !== id) return;
581
683
 
582
- // Double-check after parsing response
583
- 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;
584
690
 
585
- // Re-check streaming status using sync ref (most current)
586
- const currentlyStreaming = isSessionStreaming(sessionRefs, id);
587
- const currentSession = sessions.get(id);
588
- const currentManager = registry.get(id);
589
- const nowHasMessages = (currentSession?.messages.length ?? 0) > 0 ||
590
- (currentManager?.messages.length ?? 0) > 0;
691
+ if (currentlyStreaming || nowHasMessages) {
692
+ // Session became active during fetch - don't overwrite
693
+ return;
694
+ }
591
695
 
592
- if (currentlyStreaming || nowHasMessages) {
593
- // 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)) {
594
702
  return;
595
703
  }
704
+ mgr.setMessages(content);
705
+ mgr.setStatus('done');
706
+ }
596
707
 
597
- if (content && Array.isArray(content) && content.length > 0) {
598
- // Update stream manager via registry
599
- const mgr = registry.get(id);
600
- if (mgr) {
601
- // Final safety check via sync ref
602
- if (isSessionStreaming(sessionRefs, id)) {
603
- return;
604
- }
605
- mgr.setMessages(content);
606
- mgr.setStatus('done');
607
- }
608
-
609
- // Update session React state
610
- setSessions(prev => {
611
- const updated = new Map(prev);
612
- const existing = updated.get(id);
613
- if (existing) {
614
- updated.set(id, { ...existing, messages: content, status: 'done' });
615
- }
616
- return updated;
617
- });
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' });
618
714
  }
619
- }
620
- } catch (err) {
621
- // Ignore abort errors - they're expected when user switches tabs (#1000101)
622
- if (err instanceof Error && err.name === 'AbortError') {
623
- return;
624
- }
625
- console.error('[ClaudeSessionContext] Failed to load session content:', err);
715
+ return updated;
716
+ });
626
717
  }
718
+ } catch (err) {
719
+ console.error('[ClaudeSessionContext] Failed to load session content:', err);
627
720
  }
721
+ }
628
722
 
629
- // Flush buffered messages for the previous session
630
- if (previousSessionId && previousSessionId !== id) {
631
- const bufferedMessages = messageBuffer.flushBuffer(previousSessionId);
632
- if (bufferedMessages.length > 0) {
633
- const prevManager = registry.get(previousSessionId);
634
- if (prevManager) {
635
- // Apply buffered messages to the stream manager
636
- const existingMessages = prevManager.messages;
637
- prevManager.setMessages([...existingMessages, ...bufferedMessages]);
638
- }
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]);
639
732
  }
640
- messageBuffer.stopBuffering(previousSessionId);
641
733
  }
734
+ messageBuffer.stopBuffering(previousSessionId);
735
+ }
642
736
 
643
- // Clear tracking ref when switch completes
644
- if (switchingToRef.current === id) {
645
- switchingToRef.current = null;
646
- }
647
- sessionRefs.isTabSwitching.set(false);
648
- }, 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);
649
743
  }, [sessions, sessionRefs, registry, ensureStreamManager, setActiveSessionId, messageBuffer]);
650
744
 
651
745
  // Close a session
@@ -655,26 +749,20 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
655
749
  const session = sessions.get(sessionId);
656
750
  const sessionType = session?.type || 'standalone';
657
751
 
658
- // Unpin the session being closed
659
- const endpoint = sessionType === 'workitem'
660
- ? `/api/claude/${sessionId}/pin`
661
- : `/api/claude/sessions/${sessionId}/pin`;
662
- fetch(endpoint, { method: 'DELETE' }).catch(() => {});
663
-
664
752
  // Delete stream manager from registry (handles destroy + cleanup)
665
753
  registry.delete(sessionId);
666
754
 
667
755
  // Clear streaming status in sync ref
668
756
  setSessionStreaming(sessionRefs, sessionId, false);
669
757
 
758
+ // Persist close to DB first — prevents sessions reappearing after refresh
670
759
  try {
671
- await fetch(`/api/claude/sessions?sessionId=${sessionId}&type=${sessionType}`, {
672
- method: 'DELETE',
673
- });
760
+ await invokeWithTimeout('db_close_claude_session', { id: Number(sessionId) }, 5000);
674
761
  } catch (err) {
675
- console.error('[ClaudeSessionContext] Failed to close session:', err);
762
+ console.error('Failed to close session in DB:', sessionId, err);
676
763
  }
677
764
 
765
+ // Update UI state after DB persistence
678
766
  setStandaloneSessions(prev => prev.filter(s => s.id !== sessionId));
679
767
  setSessions(prev => {
680
768
  const updated = new Map(prev);
@@ -704,30 +792,16 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
704
792
  // With per-session streams, each new session gets its own stream manager in registry
705
793
  const createNewSession = useCallback(async () => {
706
794
  if (!usageAllowed) {
707
- showToast('Weekly limit reached. Upgrade to continue using Claude features.', 'error');
708
795
  return;
709
796
  }
710
797
 
711
798
  try {
712
- const response = await fetch('/api/claude/sessions', {
713
- method: 'POST',
714
- headers: { 'Content-Type': 'application/json' },
715
- body: JSON.stringify({ title: 'New Session' }),
799
+ const sessionId = await invoke<number>('db_create_claude_session', {
800
+ title: 'New Session',
801
+ sessionTitle: null,
716
802
  });
717
-
718
- if (!response.ok) {
719
- const errorData = await response.json();
720
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
721
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
722
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
723
- } else {
724
- console.error('[ClaudeSessionContext] Failed to create session:', errorData.error);
725
- }
726
- return;
727
- }
728
-
729
- const { id, title } = await response.json();
730
- // API returns string IDs
803
+ const id = String(sessionId);
804
+ const title = 'New Session';
731
805
 
732
806
  setStandaloneSessions(prev => [...prev, {
733
807
  id,
@@ -738,7 +812,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
738
812
 
739
813
  // Create stream manager in registry
740
814
  getOrCreateStreamManager(id, true, handleWorkItemCreated);
741
- registry.acquire(id);
815
+ registry.recordActivity(id);
742
816
 
743
817
  const newSession: Session = {
744
818
  id,
@@ -749,6 +823,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
749
823
  error: null,
750
824
  exitCode: null,
751
825
  narratedMode: true,
826
+ fullReadoutMode: false,
752
827
  };
753
828
 
754
829
  setSessions(prev => {
@@ -768,7 +843,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
768
843
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, usageAllowed, refreshUsage]);
769
844
 
770
845
  // Open the session panel (restore last session or create new)
771
- 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
+
772
852
  const savedSessionId = sessionStorage.getItem(ACTIVE_SESSION_KEY);
773
853
 
774
854
  if (savedSessionId && sessions.has(savedSessionId)) {
@@ -784,14 +864,16 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
784
864
  return;
785
865
  }
786
866
 
787
- // No sessions exist - create new standalone session
788
- createNewSession();
867
+ // No sessions exist - create new standalone session (unless over usage limit)
868
+ if (usageAllowed) {
869
+ await createNewSession();
870
+ }
789
871
  setClaudePanelOpen(true);
790
- }, [sessions, switchSession, createNewSession]);
872
+ }, [sessions, switchSession, createNewSession, usageAllowed, loadSessions]);
791
873
 
792
874
  // Send message via the active session's stream manager
793
875
  // With per-session streams, each session has its own manager - no cross-contamination possible
794
- const sendMessage = useCallback((message: string) => {
876
+ const sendMessage = useCallback((message: string, images?: AttachedImage[]) => {
795
877
  // Use sync ref for current active session (avoids stale closure)
796
878
  const currentActiveId = sessionRefs.activeSessionId.current;
797
879
  if (!currentActiveId) {
@@ -810,13 +892,13 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
810
892
  if (!session) return;
811
893
  const mgr = ensureStreamManager(currentActiveId, session);
812
894
  machine.send('SEND');
813
- mgr.sendMessage(message);
895
+ mgr.sendMessage(message, images);
814
896
  return;
815
897
  }
816
898
 
817
899
  machine.send('SEND');
818
900
  setSessionStreaming(sessionRefs, currentActiveId, true);
819
- streamManager.sendMessage(message);
901
+ streamManager.sendMessage(message, images);
820
902
  return;
821
903
  }
822
904
 
@@ -826,7 +908,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
826
908
  if (!streamManager) return;
827
909
 
828
910
  machine.send('QUEUE');
829
- streamManager.queueMessage(message);
911
+ streamManager.queueMessage(message, images);
830
912
  return;
831
913
  }
832
914
 
@@ -875,45 +957,73 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
875
957
  setSessionStreaming(sessionRefs, currentActiveId, false);
876
958
  }, [sessionRefs, registry, getStateMachine]);
877
959
 
878
- // Toggle narrated mode for the active session's stream manager
960
+ // Toggle narrated mode directly in React state, keeping stream manager in sync
961
+ // without triggering notifyStateChange (which would overwrite React state with
962
+ // potentially stale stream manager state — the root cause of the blank chat bug).
879
963
  const toggleNarratedMode = useCallback(() => {
880
964
  const currentActiveId = sessionRefs.activeSessionId.current;
881
965
  if (!currentActiveId) return;
882
966
 
883
- const streamManager = registry.get(currentActiveId);
884
- if (!streamManager) return;
967
+ setSessions(prev => {
968
+ const session = prev.get(currentActiveId);
969
+ if (!session) return prev;
970
+
971
+ const newNarratedMode = !session.narratedMode;
972
+
973
+ // Keep stream manager in sync (quiet — no notifyStateChange)
974
+ const streamManager = registry.get(currentActiveId);
975
+ if (streamManager) {
976
+ streamManager.setNarratedModeQuiet(newNarratedMode);
977
+ }
885
978
 
886
- streamManager.setNarratedMode(!streamManager.narratedMode);
979
+ const updated = new Map(prev);
980
+ updated.set(currentActiveId, {
981
+ ...session,
982
+ narratedMode: newNarratedMode,
983
+ });
984
+ return updated;
985
+ });
986
+ }, [sessionRefs, registry]);
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
+ });
887
1011
  }, [sessionRefs, registry]);
888
1012
 
889
1013
  // Create an "Add to Backlog" session with initial assistant message
890
1014
  // With per-session streams, each session has its own manager in registry
891
1015
  const createAddToBacklogSession = useCallback(async () => {
892
1016
  if (!usageAllowed) {
893
- showToast('Weekly limit reached. Upgrade to continue using Claude features.', 'error');
894
1017
  return;
895
1018
  }
896
1019
 
897
1020
  try {
898
- const response = await fetch('/api/claude/sessions', {
899
- method: 'POST',
900
- headers: { 'Content-Type': 'application/json' },
901
- 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,
902
1024
  });
903
-
904
- if (!response.ok) {
905
- const errorData = await response.json();
906
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
907
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
908
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
909
- } else {
910
- console.error('[ClaudeSessionContext] Failed to create backlog session:', errorData.error);
911
- }
912
- return;
913
- }
914
-
915
- const { id, title } = await response.json();
916
- // API returns string IDs
1025
+ const id = String(sessionId);
1026
+ const title = 'Add to Backlog';
917
1027
 
918
1028
  setStandaloneSessions(prev => [...prev, {
919
1029
  id,
@@ -925,13 +1035,13 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
925
1035
  // Create initial assistant message
926
1036
  const initialMessage: ClaudeMessage = {
927
1037
  type: 'assistant',
928
- content: 'What should we add to the backlog?',
1038
+ content: 'What should the name of this work item be?',
929
1039
  timestamp: Date.now(),
930
1040
  };
931
1041
 
932
1042
  // Create stream manager in registry
933
1043
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
934
- registry.acquire(id);
1044
+ registry.recordActivity(id);
935
1045
  // Initialize with the welcome message
936
1046
  streamManager.setMessages([initialMessage]);
937
1047
 
@@ -944,6 +1054,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
944
1054
  error: null,
945
1055
  exitCode: null,
946
1056
  narratedMode: true,
1057
+ fullReadoutMode: false,
947
1058
  };
948
1059
 
949
1060
  setSessions(prev => {
@@ -965,22 +1076,12 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
965
1076
  // Create a welcome session for blank projects with initial assistant message
966
1077
  const createWelcomeSession = useCallback(async () => {
967
1078
  try {
968
- const response = await fetch('/api/claude/sessions', {
969
- method: 'POST',
970
- headers: { 'Content-Type': 'application/json' },
971
- body: JSON.stringify({ title: 'Welcome' }),
1079
+ const sessionId = await invoke<number>('db_create_claude_session', {
1080
+ title: 'Welcome',
1081
+ sessionTitle: null,
972
1082
  });
973
-
974
- if (!response.ok) {
975
- const errorData = await response.json();
976
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
977
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
978
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
979
- }
980
- return;
981
- }
982
-
983
- const { id, title } = await response.json();
1083
+ const id = String(sessionId);
1084
+ const title = 'Welcome';
984
1085
 
985
1086
  setStandaloneSessions(prev => [...prev, {
986
1087
  id,
@@ -1030,7 +1131,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1030
1131
  const welcomeMessages = [greetingMessage, backlogTip, workflowTip, ctaMessage];
1031
1132
 
1032
1133
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1033
- registry.acquire(id);
1134
+ registry.recordActivity(id);
1034
1135
  streamManager.setMessages(welcomeMessages);
1035
1136
 
1036
1137
  const newSession: Session = {
@@ -1042,6 +1143,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1042
1143
  error: null,
1043
1144
  exitCode: null,
1044
1145
  narratedMode: false,
1146
+ fullReadoutMode: false,
1045
1147
  };
1046
1148
 
1047
1149
  setSessions(prev => {
@@ -1057,34 +1159,84 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1057
1159
  }
1058
1160
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId]);
1059
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
+
1060
1226
  // Create a "Run Scenario" session with preloaded cucumber-js command
1061
1227
  const createRunScenarioSession = useCallback(async (featureFile: string, scenarioTitle: string) => {
1062
1228
  if (!usageAllowed) {
1063
- showToast('Weekly limit reached. Upgrade to continue using Claude features.', 'error');
1064
1229
  return;
1065
1230
  }
1066
1231
 
1067
1232
  try {
1068
1233
  const sessionTitle = `Run: ${scenarioTitle}`;
1069
- const response = await fetch('/api/claude/sessions', {
1070
- method: 'POST',
1071
- headers: { 'Content-Type': 'application/json' },
1072
- body: JSON.stringify({ title: sessionTitle }),
1234
+ const sessionId = await invoke<number>('db_create_claude_session', {
1235
+ title: sessionTitle,
1236
+ sessionTitle: null,
1073
1237
  });
1074
-
1075
- if (!response.ok) {
1076
- const errorData = await response.json();
1077
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
1078
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
1079
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
1080
- } else {
1081
- console.error('[ClaudeSessionContext] Failed to create run scenario session:', errorData.error);
1082
- showToast('Failed to create session. Please try again.', 'error');
1083
- }
1084
- return;
1085
- }
1086
-
1087
- const { id, title } = await response.json();
1238
+ const id = String(sessionId);
1239
+ const title = sessionTitle;
1088
1240
 
1089
1241
  setStandaloneSessions(prev => [...prev, {
1090
1242
  id,
@@ -1100,7 +1252,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1100
1252
  };
1101
1253
 
1102
1254
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1103
- registry.acquire(id);
1255
+ registry.recordActivity(id);
1104
1256
  streamManager.setMessages([userMessage]);
1105
1257
 
1106
1258
  const newSession: Session = {
@@ -1112,6 +1264,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1112
1264
  error: null,
1113
1265
  exitCode: null,
1114
1266
  narratedMode: true,
1267
+ fullReadoutMode: false,
1115
1268
  };
1116
1269
 
1117
1270
  setSessions(prev => {
@@ -1139,31 +1292,17 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1139
1292
  // Create a "Fix Scenario" session with preloaded failure context
1140
1293
  const createFixScenarioSession = useCallback(async (featureFile: string, scenarioTitle: string, error: string, failedStep?: string, steps?: string[]) => {
1141
1294
  if (!usageAllowed) {
1142
- showToast('Weekly limit reached. Upgrade to continue using Claude features.', 'error');
1143
1295
  return;
1144
1296
  }
1145
1297
 
1146
1298
  try {
1147
1299
  const sessionTitle = `Fix: ${scenarioTitle}`;
1148
- const response = await fetch('/api/claude/sessions', {
1149
- method: 'POST',
1150
- headers: { 'Content-Type': 'application/json' },
1151
- body: JSON.stringify({ title: sessionTitle }),
1300
+ const sessionId = await invoke<number>('db_create_claude_session', {
1301
+ title: sessionTitle,
1302
+ sessionTitle: null,
1152
1303
  });
1153
-
1154
- if (!response.ok) {
1155
- const errorData = await response.json();
1156
- if (errorData.code === 'SESSION_LIMIT_REACHED') {
1157
- console.warn(`[ClaudeSessionContext] ${errorData.error}`);
1158
- showToast('Session limit reached. Close existing sessions to create new ones.', 'error');
1159
- } else {
1160
- console.error('[ClaudeSessionContext] Failed to create fix scenario session:', errorData.error);
1161
- showToast('Failed to create session. Please try again.', 'error');
1162
- }
1163
- return;
1164
- }
1165
-
1166
- const { id, title } = await response.json();
1304
+ const id = String(sessionId);
1305
+ const title = sessionTitle;
1167
1306
 
1168
1307
  setStandaloneSessions(prev => [...prev, {
1169
1308
  id,
@@ -1199,7 +1338,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1199
1338
  };
1200
1339
 
1201
1340
  const streamManager = getOrCreateStreamManager(id, true, handleWorkItemCreated);
1202
- registry.acquire(id);
1341
+ registry.recordActivity(id);
1203
1342
  streamManager.setMessages([userMessage]);
1204
1343
 
1205
1344
  const newSession: Session = {
@@ -1211,6 +1350,7 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1211
1350
  error: null,
1212
1351
  exitCode: null,
1213
1352
  narratedMode: true,
1353
+ fullReadoutMode: false,
1214
1354
  };
1215
1355
 
1216
1356
  setSessions(prev => {
@@ -1235,6 +1375,150 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1235
1375
  }
1236
1376
  }, [showToast, registry, getOrCreateStreamManager, handleWorkItemCreated, setActiveSessionId, getStateMachine, usageAllowed, refreshUsage]);
1237
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
+
1238
1522
  // Setters for direct manipulation (e.g., restoring from DB)
1239
1523
  // These now work through the registry
1240
1524
  const setMessages = useCallback((messages: ClaudeMessage[]) => {
@@ -1261,18 +1545,13 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1261
1545
  // Get canRetry from registry
1262
1546
  const activeStreamManager = activeSessionId ? registry.get(activeSessionId) : null;
1263
1547
 
1264
- const value: ClaudeSessionContextValue = {
1265
- // Panel state
1548
+ // Memoize state context — changes when state values change
1549
+ const stateValue: SessionStateContextValue = useMemo(() => ({
1266
1550
  claudePanelOpen,
1267
- setClaudePanelOpen,
1268
-
1269
- // Session state
1270
1551
  sessions,
1271
1552
  activeSessionId,
1272
1553
  activeSession,
1273
1554
  standaloneSessions,
1274
-
1275
- // Stream state (from active session)
1276
1555
  messages: activeSession?.messages ?? [],
1277
1556
  status: activeSession?.status ?? 'idle',
1278
1557
  error: activeSession?.error ?? null,
@@ -1280,9 +1559,14 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1280
1559
  canRetry: activeStreamManager?.canRetry ?? false,
1281
1560
  queuedMessage: activeStreamManager?.queuedMessage ?? null,
1282
1561
  narratedMode: activeSession?.narratedMode ?? false,
1283
- toggleNarratedMode,
1562
+ fullReadoutMode: activeSession?.fullReadoutMode ?? false,
1563
+ rawEvents: activeStreamManager?.rawEvents ?? [],
1564
+ isTabSwitching,
1565
+ }), [claudePanelOpen, sessions, activeSessionId, activeSession, standaloneSessions, activeStreamManager, isTabSwitching]);
1284
1566
 
1285
- // Session actions
1567
+ // Memoize actions context — stable callbacks, rarely changes
1568
+ const actionsValue: SessionActionsContextValue = useMemo(() => ({
1569
+ setClaudePanelOpen,
1286
1570
  openSession,
1287
1571
  switchSession,
1288
1572
  closeSession,
@@ -1291,24 +1575,33 @@ export function ClaudeSessionProvider({ children }: { children: ReactNode }) {
1291
1575
  createAddToBacklogSession,
1292
1576
  createRunScenarioSession,
1293
1577
  createFixScenarioSession,
1578
+ createFixServiceSession,
1579
+ createBddSetupSession,
1294
1580
  createWelcomeSession,
1295
-
1296
- // Stream actions (now go through registry)
1581
+ createSkillSession,
1297
1582
  sendMessage,
1298
1583
  retry,
1299
1584
  stop,
1585
+ toggleNarratedMode,
1586
+ toggleFullReadout,
1587
+ }), [setClaudePanelOpen, openSession, switchSession, closeSession, openSessionPanel, createNewSession, createAddToBacklogSession, createRunScenarioSession, createFixScenarioSession, createFixServiceSession, createBddSetupSession, createWelcomeSession, createSkillSession, sendMessage, retry, stop, toggleNarratedMode, toggleFullReadout]);
1300
1588
 
1301
- // Direct setters (work through registry)
1589
+ // Memoize persistence context stable setters
1590
+ const persistenceValue: SessionPersistenceContextValue = useMemo(() => ({
1302
1591
  setMessages,
1303
1592
  setStatus,
1304
1593
  setSessions,
1305
1594
  setActiveSessionId,
1306
1595
  setStandaloneSessions,
1307
- };
1596
+ }), [setMessages, setStatus, setSessions, setActiveSessionId, setStandaloneSessions]);
1308
1597
 
1309
1598
  return (
1310
- <ClaudeSessionContext.Provider value={value}>
1311
- {children}
1312
- </ClaudeSessionContext.Provider>
1599
+ <SessionStateContext.Provider value={stateValue}>
1600
+ <SessionActionsContext.Provider value={actionsValue}>
1601
+ <SessionPersistenceContext.Provider value={persistenceValue}>
1602
+ {children}
1603
+ </SessionPersistenceContext.Provider>
1604
+ </SessionActionsContext.Provider>
1605
+ </SessionStateContext.Provider>
1313
1606
  );
1314
1607
  }