From c9ad1bd745b34e6ceead6d167d2c8da6dc45d0d0 Mon Sep 17 00:00:00 2001 From: Kim AI Date: Tue, 31 Mar 2026 03:27:03 +0300 Subject: [PATCH] chore(release): prepare 1.5.2 (#201) * chore(release): prepare 1.5.2 * docs(changelog): include kanban assignee picker in 1.5.2 * fix(sessions): preserve status timers across resubscribe * fix(sessions): use latest gateway for delayed refreshes * fix(sessions): refresh unknown sessions via latest gateway --- .env.example | 1 + CHANGELOG.md | 38 +++++++ docs/API.md | 4 +- docs/ARCHITECTURE.md | 26 ++--- docs/CONFIGURATION.md | 6 ++ docs/DEPLOYMENT-C.md | 1 + docs/INSTALL.md | 2 +- docs/TROUBLESHOOTING.md | 32 ++++-- package-lock.json | 4 +- package.json | 2 +- src/contexts/SessionContext.test.tsx | 142 +++++++++++++++++++++++++-- src/contexts/SessionContext.tsx | 23 +++-- 12 files changed, 241 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 4e2d303..13da0be 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ AGENT_NAME=Agent GATEWAY_TOKEN= # OPENCLAW_GATEWAY_TOKEN= # Alternative name for GATEWAY_TOKEN (checked as fallback) GATEWAY_URL=http://127.0.0.1:18789 +# NERVE_PUBLIC_ORIGIN=https://your-nerve.example.com # Explicit browser origin for remote-workspace gateway RPC fallback # ─── Authentication ────────────────────────────────────────────────────────── # Enable to require a password for all API/WebSocket access. diff --git a/CHANGELOG.md b/CHANGELOG.md index 384c6f5..1a89558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.5.2] - 2026-03-30 + +### Highlights + +**Kanban execution now matches the real session tree.** Assigned tasks launch as real child sessions beneath the selected assignee root, task completion and failures report back to the parent root, and background root notifications no longer misfire while those updates land (PR #198). + +**Remote and hybrid installs are less brittle.** Nerve now supports remote-gateway installation up front via `--gateway-url`, resolves gateway RPC origins from public config for remote workspace access, and explains missing cron capability with a clear remediation path instead of a dead-end warning (PR #181, PR #197, PR #200). + +**Session and agent state are less misleading.** The model picker now reflects the active OpenClaw config, duplicate root-agent creation correctly registers suffixed agents in `openclaw.json`, direct-message sessions nest under the correct agent root, and the main root label stays canonical (PR #174, PR #185, PR #192, PR #196). + +**Docs and setup guidance caught back up to reality.** AI setup docs landed, setup now prints the right deployment guide links, and stale operator docs were refreshed to match the current runtime and installer behavior (PR #179, PR #182, PR #191). + +### Added +- Installer support for `--gateway-url` so Nerve can target a remote gateway from first boot (PR #181) +- AI agent setup docs and a raw install contract for agent-driven installs (PR #182) +- A dedicated `GET /api/kanban/tasks/:id` endpoint for direct Kanban task lookup by id (PR #176) +- An assignee picker for Kanban task forms so users no longer need to enter raw assignee values manually (PR #203) +- Support for custom board column keys via board config (PR #173) +- Shebang-based syntax highlighting for extensionless executable files (PR #190) + +### Changed +- Setup now prints deployment guide links after configuration so operators can jump straight to the right topology docs (PR #179) +- Setup now ensures `sessions_spawn` is allowlisted alongside the other required gateway tools for Kanban execution on current OpenClaw builds (PR #159) +- Model selection now comes from the active OpenClaw config instead of Nerve-side fallback lists (PR #174) +- Chat input helper text now points users at the command palette more clearly (PR #175) + +### Fixed +- Skills API parsing now falls back to structured stderr JSON output when tools emit machine-readable results there (PR #161) +- Sidebar session tree cleanup: only real roots are shown, direct-message sessions nest under their owning agent root, and `agent:main:main` always renders with a canonical label (PR #177, PR #185, PR #196) +- Session selection click targets are more forgiving thanks to a small hover delay that reduces accidental steals while moving through the tree (PR #187) +- Duplicate root-agent creation now registers the correct suffixed agent in `openclaw.json` so config, workspace, and session roots stay aligned (PR #192) +- Assigned Kanban tasks now launch as real child sessions, clean up orphaned child sessions on partial launch failures, and report completion back to the parent root that owns the work (PR #198) +- Background top-level root updates now set unread state correctly and only ping on terminal events (PR #198) +- Remote-workspace gateway RPC now derives its request origin from public config instead of hardcoded loopback values, fixing hybrid/cloud `origin not allowed` failures (PR #200) + +### Documentation +- Added AI setup docs and refreshed stale repo docs so installation, deployment, configuration, and troubleshooting guidance line up with the current runtime (PR #182, PR #191) + ## [1.5.1] - 2026-03-25 ### Fixed diff --git a/docs/API.md b/docs/API.md index caa8117..7efcbf7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1737,7 +1737,7 @@ Execute a task and move it to `in-progress`. The launch path depends on the task **Response:** The updated `KanbanTask` object with `status: "in-progress"` and a `run` object. **Execution paths:** -- **Assigned tasks** run through the assignee's live root session. Nerve verifies that parent root exists, then asks it to spawn a child worker. +- **Assigned tasks** create a real child session beneath the assignee's live root. Nerve verifies that the parent root exists, creates the child with `sessions.create(parentSessionKey=...)`, then sends the task into that child with `sessions.send`. - **Unassigned or `operator` tasks** use the normal `sessions_spawn` path. - **macOS fallback rule:** unassigned or `operator` tasks are rejected. Assign the task to a live worker root first. @@ -1752,6 +1752,8 @@ Execute a task and move it to `in-progress`. The launch path depends on the task **Notes:** - The spawned worker receives the task title and description as its prompt. +- Assigned-task runs keep both a deterministic run correlation key and the real `childSessionKey`. +- When an assigned child session finishes or fails, Nerve sends a completion report back to the parent root session. - Backend pollers run every 5 seconds for up to **720 attempts / 60 minutes**. - On success the task moves to `review`. On error it moves back to `todo`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4e84cc0..9a3bd1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -576,9 +576,12 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien | +-- gatewayRpcCall('sessions.list', ...) confirms the parent root exists | +-- store.executeTask(..., { sessionKey }) -> status = in-progress, run.status = running | +-- launchKanbanFallbackSubagentViaRpc({ label, task, parentSessionKey, model?, thinking? }) - | +-- gatewayRpcCall('chat.send', { sessionKey: parentSessionKey, message:'[spawn-subagent]...', idempotencyKey }) - | +-- attach returned runId when available - | +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, expectedChildLabel, knownSessionKeysBefore, runId? }) + | +-- gatewayRpcCall('sessions.create', { key: childSessionKey, parentSessionKey, label, model? }) + | +-- gatewayRpcCall('sessions.send', { key: childSessionKey, message: task, thinking?, idempotencyKey }) + | +-- if send fails after create: best-effort gatewayRpcCall('sessions.delete', { key: childSessionKey, deleteTranscript: true }) + | +-- return correlationKey + childSessionKey + runId? + | +-- attach childSessionKey / runId immediately when available + | +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, childSessionKey?, expectedChildLabel, knownSessionKeysBefore, runId? }) | +-- else if task is unassigned / operator: | +-- on macOS: return 409 invalid_execution_target @@ -592,17 +595,16 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien 2. pollSessionCompletion() / pollFallbackSessionCompletion() +-- sessions_spawn path polls gateway subagents by correlation key / childSessionKey / runId +-- assignee-root path polls gateway RPC sessions.list every 5s (max 720 attempts / 60 min) - +-- assignee-root path matches the spawned child beneath the parent root and attaches childSessionKey - +-- both paths complete the task when the child reports terminal success/failure - +-- if session not found yet: - +-- schedule next poll - +-- if session is idle and not busy/processing: - | fetch session history (last 3 messages) + +-- assignee-root path prefers the known childSessionKey; otherwise it discovers the new child beneath the parent root and attaches it + +-- if the child completes successfully: + | fetch child history via sessions.get / sessions_history | parseKanbanMarkers(resultText) -> create proposals | stripKanbanMarkers(resultText) -> clean result - +-- store.completeRun(taskId, sessionKey, cleanResult) + | store.completeRun(taskId, sessionKey, cleanResult) + +-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: completionReport }) +-- if status=error/failed: - +-- store.completeRun(taskId, sessionKey, undefined, errorMsg) + | store.completeRun(taskId, sessionKey, undefined, errorMsg) + +-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: failureReport }) +-- if task/run no longer matches the active session key: +-- stop polling as stale +-- otherwise: @@ -613,7 +615,7 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien +-- error -> run.status = error, task.status = todo ``` -The model cascade is: execute request `model` -> task `model` -> board config `defaultModel` -> OpenClaw's configured default model. Thinking follows the same pattern with `defaultThinking`. +Assigned-root execution now uses real session primitives instead of synthetic marker-message spawn conventions. The model cascade is: execute request `model` -> task `model` -> board config `defaultModel` -> OpenClaw's configured default model. Thinking follows the same pattern with `defaultThinking`. ### Marker Parsing diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b5bebd4..2e5cac3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -109,10 +109,14 @@ HOST=127.0.0.1 |----------|---------|----------|-------------| | `GATEWAY_TOKEN` | — | **Yes** | Authentication token for the OpenClaw gateway. The setup wizard auto-detects this. See note below | | `GATEWAY_URL` | `http://127.0.0.1:18789` | No | Gateway HTTP endpoint URL | +| `NERVE_PUBLIC_ORIGIN` | *(empty)* | No | Explicit browser-facing Nerve origin used when server-side gateway RPC fallback must open its own WebSocket to OpenClaw. Useful for reverse-proxy, cloud, and hybrid deployments. | ```bash GATEWAY_TOKEN=your-token-here GATEWAY_URL=http://127.0.0.1:18789 + +# Optional for reverse-proxy / cloud / hybrid installs +NERVE_PUBLIC_ORIGIN=https://nerve.example.com ``` For non-interactive installs that should talk to a remote gateway, pass the URL directly to the installer: @@ -122,6 +126,8 @@ curl -fsSL https://raw.githubusercontent.com/daggerhashimoto/openclaw-nerve/mast | bash -s -- --gateway-url https://gw.example.com --gateway-token --skip-setup ``` +If remote workspace panels (Files, Memory, Config, Skills) fail with `origin not allowed` while chat still works, set `NERVE_PUBLIC_ORIGIN` to the exact browser origin and add that same origin to `gateway.controlUi.allowedOrigins` on the gateway. + ### Token Injection Nerve performs **server-side token injection**. When a connection is established through the WebSocket proxy, Nerve automatically injects the configured `GATEWAY_TOKEN` into the connection request if the client is considered **trusted**. diff --git a/docs/DEPLOYMENT-C.md b/docs/DEPLOYMENT-C.md index cdaf72e..53b4961 100644 --- a/docs/DEPLOYMENT-C.md +++ b/docs/DEPLOYMENT-C.md @@ -86,6 +86,7 @@ In `.env`: ```bash GATEWAY_URL= WS_ALLOWED_HOSTS= +NERVE_PUBLIC_ORIGIN=https://nerve.example.com ``` ### Patch remote gateway allowed origins diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0397b78..5487d96 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -51,7 +51,7 @@ You must: You may apply minimal localhost-safe OpenClaw changes automatically when needed for the default local path. Examples: - adding missing local control UI origins -- adding required gateway tool allow entries such as `cron` and `gateway` +- adding required gateway tool allow entries such as `cron`, `gateway`, and `sessions_spawn` - fixing local device pairing or scopes needed for Nerve to connect Ask first before any OpenClaw change that is remote, public, security-sensitive, destructive, or changes network exposure. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 58b5035..bbb946a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,20 @@ rm ~/.nerve/device-identity.json WS_ALLOWED_HOSTS=mygateway.local npm start ``` +### Workspace panels fail with `origin not allowed` + +**Symptom:** Chat connects, but remote workspace panels like Files, Memory, Config, or Skills fail with gateway `origin not allowed` errors. + +**Cause:** Those panels can use the server-side gateway RPC fallback, which opens its own WebSocket to the gateway. If Nerve does not know its real browser-facing origin, that fallback can present the wrong origin even though the browser chat path is already working. + +**Fix:** Set the exact browser origin in `.env` and allow the same origin on the gateway: + +```bash +NERVE_PUBLIC_ORIGIN=https://nerve.example.com +``` + +Also add `https://nerve.example.com` to `gateway.controlUi.allowedOrigins`, then restart both Nerve and the gateway. + ### "device token mismatch" on WebSocket connect **Symptom:** Server logs show `[ws-proxy] Gateway closed: code=1008, reason=unauthorized: device token mismatch`. @@ -440,28 +454,30 @@ MEMORY_PATH=/path/to/.openclaw/workspace/MEMORY.md **Symptom:** "Timed out waiting for subagent to spawn" error. -**Cause:** Spawning uses a polling approach, sends a `[spawn-subagent]` chat message to the selected root session, then polls `sessions.list` every 1s for up to 60s waiting for a new subagent session to appear. +**Cause:** Nerve requested a child session, but the gateway never surfaced a matching worker session before the timeout. Depending on the path, that usually means the selected root session could not launch the child, the normal `sessions_spawn` path failed, or the child session metadata never became visible to Nerve's poller. **Fix:** -- The selected root agent must be running and able to process the spawn request -- Check that the selected root session isn't busy with another task -- Check gateway logs for spawn errors +- Make sure the selected root agent exists and is healthy +- Check whether that root is already busy with another task +- Inspect gateway logs for `sessions.create`, `sessions.send`, or `sessions_spawn` failures +- Refresh sessions and retry after gateway reconnects if the session tree looks stale -### Kanban task execution cannot attach to a spawned worker +### Kanban task execution cannot attach to a worker -**Symptom:** A Kanban task enters `in-progress`, but the worker session never links up cleanly, or completion only works by label fallback. +**Symptom:** A Kanban task enters `in-progress`, but no worker session links up cleanly, the task never reaches `review`, or the parent root never gets the completion update. **Cause:** Kanban has two execution paths now: -- **Assigned tasks** run beneath the assignee's live root session via RPC `[spawn-subagent]`. +- **Assigned tasks** create a real child session beneath the assignee's live root via `sessions.create(parentSessionKey=...)`, then send the task with `sessions.send`. - **Unassigned or `operator` tasks** use the normal `sessions_spawn` path. -That means failures can come from either a missing assignee root, a delayed child discovery step, or the normal subagent spawn path. On macOS, unassigned or `operator` tasks are rejected outright and must be assigned to a live worker root first. +That means failures can come from a missing assignee root, a child-session create/send failure, the normal `sessions_spawn` path, or a stalled completion poller. On macOS, unassigned or `operator` tasks are rejected outright and must be assigned to a live worker root first. **Fix:** - If the task is assigned, make sure that assignee's root session exists and is healthy - If the task is unassigned, verify the normal `sessions_spawn` path is working - On macOS, assign the task to a live worker root before executing it - Check gateway RPC/session logs for the assignee-root path, and HTTP tool logs for the normal spawn path +- If the child session finishes but the parent root never updates, inspect gateway RPC logs and recent session events for the parent-report step - If the worker never appears in the session list, inspect gateway connectivity and recent session events first ### Session status stuck on "THINKING" diff --git a/package-lock.json b/package-lock.json index 006c6ff..6c9b5b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openclaw-nerve", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openclaw-nerve", - "version": "1.5.1", + "version": "1.5.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 41902eb..c7d06b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-nerve", - "version": "1.5.1", + "version": "1.5.2", "description": "Web interface for OpenClaw — chat, voice input, TTS, and agent monitoring in the browser", "license": "MIT", "engines": { diff --git a/src/contexts/SessionContext.test.tsx b/src/contexts/SessionContext.test.tsx index b1d9365..02db1c4 100644 --- a/src/contexts/SessionContext.test.tsx +++ b/src/contexts/SessionContext.test.tsx @@ -7,6 +7,8 @@ const mockUseGateway = vi.fn(); const mockUseSettings = vi.fn(); const playPingMock = vi.fn(); let rpcMock: ReturnType; +let subscribeMock: ReturnType; +let connectionStateValue: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' = 'connected'; let subscribedHandler: ((msg: GatewayEvent) => void) | null = null; let soundEnabledValue = true; @@ -70,6 +72,7 @@ describe('SessionContext', () => { vi.clearAllMocks(); subscribedHandler = null; soundEnabledValue = true; + connectionStateValue = 'connected'; rpcMock = vi.fn(async (method: string, params?: Record) => { if (method === 'sessions.list') { @@ -90,15 +93,17 @@ describe('SessionContext', () => { return {}; }); - mockUseGateway.mockReturnValue({ - connectionState: 'connected', - rpc: rpcMock, - subscribe: vi.fn((handler: (msg: GatewayEvent) => void) => { - subscribedHandler = handler; - return () => {}; - }), + subscribeMock = vi.fn((handler: (msg: GatewayEvent) => void) => { + subscribedHandler = handler; + return () => {}; }); + mockUseGateway.mockImplementation(() => ({ + connectionState: connectionStateValue, + rpc: rpcMock, + subscribe: subscribeMock, + })); + mockUseSettings.mockImplementation(() => ({ soundEnabled: soundEnabledValue, })); @@ -377,10 +382,129 @@ describe('SessionContext', () => { }); await act(async () => { - vi.advanceTimersByTime(3_100); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_100); }); expect(screen.getByTestId('reviewer-status').textContent).toBe('IDLE'); }); + + it('uses the latest refresh callback for delayed refreshes after gateway changes', async () => { + const rpcBeforeReconnect = vi.fn(async (method: string) => { + if (method === 'sessions.list') { + return { + sessions: [ + { sessionKey: 'agent:main:main', label: 'Main' }, + { sessionKey: 'agent:reviewer:main', label: 'Reviewer' }, + ], + }; + } + return {}; + }); + const rpcAfterReconnect = vi.fn(async () => ({})); + rpcMock = rpcBeforeReconnect; + + const view = render( + + + , + ); + + await waitFor(() => { + expect(rpcBeforeReconnect).toHaveBeenCalledWith('sessions.list', { limit: 1000 }); + }); + + vi.useFakeTimers(); + + await act(async () => { + subscribedHandler?.({ + type: 'event', + event: 'chat', + payload: { + sessionKey: 'agent:reviewer:main', + state: 'final', + }, + }); + await Promise.resolve(); + }); + + const preReconnectSessionsListCalls = rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list').length; + + await act(async () => { + connectionStateValue = 'reconnecting'; + rpcMock = rpcAfterReconnect; + view.rerender( + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_600); + }); + + expect(rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(preReconnectSessionsListCalls); + expect(rpcAfterReconnect).not.toHaveBeenCalledWith('sessions.list', expect.anything()); + }); + + it('uses the latest refresh callback for missing-session fallback refreshes after gateway changes', async () => { + const rpcBeforeReconnect = vi.fn(async (method: string) => { + if (method === 'sessions.list') { + return { + sessions: [ + { sessionKey: 'agent:main:main', label: 'Main' }, + { sessionKey: 'agent:reviewer:main', label: 'Reviewer' }, + ], + }; + } + return {}; + }); + const rpcAfterReconnect = vi.fn(async () => ({})); + rpcMock = rpcBeforeReconnect; + + const view = render( + + + , + ); + + await waitFor(() => { + expect(rpcBeforeReconnect).toHaveBeenCalledWith('sessions.list', { limit: 1000 }); + }); + + vi.useFakeTimers(); + + await act(async () => { + subscribedHandler?.({ + type: 'event', + event: 'chat', + payload: { + sessionKey: 'agent:fresh:main', + state: 'started', + }, + }); + await Promise.resolve(); + }); + + const preReconnectSessionsListCalls = rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list').length; + + await act(async () => { + connectionStateValue = 'reconnecting'; + rpcMock = rpcAfterReconnect; + view.rerender( + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(preReconnectSessionsListCalls); + expect(rpcAfterReconnect).not.toHaveBeenCalledWith('sessions.list', expect.anything()); + }); }); diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx index 0ebac9a..bc837d6 100644 --- a/src/contexts/SessionContext.tsx +++ b/src/contexts/SessionContext.tsx @@ -497,6 +497,11 @@ export function SessionProvider({ children }: { children: ReactNode }) { } }, [connectionState, listAuthoritativeSessions, setCurrentSession]); + const refreshSessionsRef = useRef(refreshSessions); + useEffect(() => { + refreshSessionsRef.current = refreshSessions; + }, [refreshSessions]); + // Update session in list from WebSocket event data const updateSessionFromEvent = useCallback((sessionKey: string, updates: Partial) => { setSessions(prev => { @@ -504,7 +509,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { if (idx === -1) { // New session appeared that we don't have - schedule a refresh // Use setTimeout to avoid calling during render - setTimeout(() => refreshSessions(), 100); + setTimeout(() => { + void refreshSessionsRef.current(); + }, 100); return prev; } @@ -523,7 +530,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { return { ...s, ...updates, lastActivity: Date.now() }; }); }); - }, [refreshSessions]); + }, []); // Extract session updates (state + token data) from a typed agent event payload const extractSessionUpdates = useCallback((state: string | undefined, payload: AgentEventPayload | ChatEventPayload): Partial => { @@ -540,9 +547,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { } delayedRefreshTimeoutRef.current = setTimeout(() => { delayedRefreshTimeoutRef.current = null; - refreshSessions(); + void refreshSessionsRef.current(); }, 1500); - }, [refreshSessions]); + }, []); // Subscribe to gateway events for granular status tracking + session state sync + agent log + event log useEffect(() => { @@ -664,9 +671,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { feedAgentLog(evt, p); }); - // Cleanup: cancel all pending DONE→IDLE timeouts return () => { unsub(); + }; + }, [subscribe, addEvent, setGranularStatus, markSessionUnread, pingSession, feedAgentLog, updateSessionFromEvent, extractSessionUpdates, refreshSessions, scheduleDelayedRefresh]); + + useEffect(() => { + return () => { for (const key of Object.keys(doneTimeoutsRef.current)) { clearTimeout(doneTimeoutsRef.current[key]); } @@ -676,7 +687,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { delayedRefreshTimeoutRef.current = null; } }; - }, [subscribe, addEvent, setGranularStatus, markSessionUnread, pingSession, feedAgentLog, updateSessionFromEvent, extractSessionUpdates, refreshSessions, scheduleDelayedRefresh]); + }, []); // Poll sessions when connected (reduced to 30s - WebSocket events provide real-time updates) useEffect(() => {