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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
fullReadoutMode: boolean;
|
|
65
|
+
rawEvents: unknown[];
|
|
66
|
+
isTabSwitching: boolean;
|
|
67
|
+
}
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
+
const SessionStateContext = createContext<SessionStateContextValue | null>(null);
|
|
99
|
+
const SessionActionsContext = createContext<SessionActionsContextValue | null>(null);
|
|
100
|
+
const SessionPersistenceContext = createContext<SessionPersistenceContextValue | null>(null);
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
179
|
-
const
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
...
|
|
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
|
|
292
|
-
|
|
293
|
-
if (!
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
339
|
-
|
|
397
|
+
// Update active session ID to the work item ID
|
|
398
|
+
setActiveSessionId(newSessionId);
|
|
340
399
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
405
|
+
console.error('Failed to re-key session after link:', err);
|
|
348
406
|
}
|
|
349
|
-
}, [
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
const
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
428
|
-
|
|
508
|
+
return updated;
|
|
509
|
+
});
|
|
429
510
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
580
|
-
|
|
681
|
+
// Check if user switched to a different session while we were fetching
|
|
682
|
+
if (switchingToRef.current !== id) return;
|
|
581
683
|
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
672
|
-
method: 'DELETE',
|
|
673
|
-
});
|
|
760
|
+
await invokeWithTimeout('db_close_claude_session', { id: Number(sessionId) }, 5000);
|
|
674
761
|
} catch (err) {
|
|
675
|
-
console.error('
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1562
|
+
fullReadoutMode: activeSession?.fullReadoutMode ?? false,
|
|
1563
|
+
rawEvents: activeStreamManager?.rawEvents ?? [],
|
|
1564
|
+
isTabSwitching,
|
|
1565
|
+
}), [claudePanelOpen, sessions, activeSessionId, activeSession, standaloneSessions, activeStreamManager, isTabSwitching]);
|
|
1284
1566
|
|
|
1285
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
1311
|
-
{
|
|
1312
|
-
|
|
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
|
}
|