From 97e8b6a288b8efbaf6c228c82ab9758c93aae287 Mon Sep 17 00:00:00 2001 From: dimakis Date: Tue, 28 Apr 2026 08:41:20 +0100 Subject: [PATCH 01/45] fix(watch): add WKCompanionAppBundleIdentifier to Info.plist watchOS requires the companion app bundle ID in the watch app's Info.plist to install on device. Made-with: Cursor --- frontend/ios/MitzoWatch/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/ios/MitzoWatch/Info.plist b/frontend/ios/MitzoWatch/Info.plist index 3b31c892..79a8a33c 100644 --- a/frontend/ios/MitzoWatch/Info.plist +++ b/frontend/ios/MitzoWatch/Info.plist @@ -24,5 +24,7 @@ Mitzo uses the microphone for voice input to send messages. WKApplication + WKCompanionAppBundleIdentifier + com.mitzo.app From 6c4cd54b2aad4329c3468b9b4f5a697d8067b02f Mon Sep 17 00:00:00 2001 From: dimakis Date: Tue, 28 Apr 2026 17:05:41 +0100 Subject: [PATCH 02/45] fix(watch): trust Tailscale certs + enable shared Keychain on iOS - Add TailscaleTrustDelegate that accepts TLS certs for *.ts.net, *.tail, and localhost hosts (matching Capacitor allowNavigation). - Replace URLSession.shared with tailscaleURLSession in AuthManager, MitzoAPIClient, and MitzoWSClient. Fixes "Login failed" on watch caused by URLSession rejecting the self-signed Tailscale cert. - Add keychain-access-groups to iOS App.entitlements so both iPhone and watch apps share the same Keychain access group. Made-with: Cursor --- frontend/ios/App/App/App.entitlements | 4 +++ .../MitzoShared/Auth/AuthManager.swift | 2 +- .../Networking/MitzoAPIClient.swift | 2 +- .../Networking/MitzoWSClient.swift | 3 +- .../Networking/TailscaleTrust.swift | 35 +++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift diff --git a/frontend/ios/App/App/App.entitlements b/frontend/ios/App/App/App.entitlements index 903def2a..ac50cce5 100644 --- a/frontend/ios/App/App/App.entitlements +++ b/frontend/ios/App/App/App.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)com.mitzo.app + diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift index db34ddf2..702858b3 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift @@ -120,7 +120,7 @@ public actor AuthManager { let body = ["passphrase": passphrase] request.httpBody = try JSONEncoder().encode(body) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await tailscaleURLSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift index ac3a0324..f4844187 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift @@ -51,7 +51,7 @@ public actor MitzoAPIClient { let token = try await authManager.getToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await tailscaleURLSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift index 247943f9..c5eb9f8c 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift @@ -108,8 +108,7 @@ public actor MitzoWSClient { state = .connecting eventHandler?(.stateChanged(.connecting)) - let session = URLSession(configuration: .default) - wsTask = session.webSocketTask(with: url) + wsTask = tailscaleURLSession.webSocketTask(with: url) wsTask?.resume() // Send hello diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift new file mode 100644 index 00000000..b1fa6fbb --- /dev/null +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift @@ -0,0 +1,35 @@ +// URLSession that trusts Tailscale / self-signed certificates. +// Mitzo runs over Tailscale with HTTPS; the cert isn't in the +// system trust store, so URLSession rejects it by default. + +import Foundation + +/// URLSession delegate that accepts TLS certificates for *.ts.net +/// and localhost hosts (matching Capacitor's allowNavigation config). +public final class TailscaleTrustDelegate: NSObject, URLSessionDelegate, Sendable { + public static let shared = TailscaleTrustDelegate() + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + + let host = challenge.protectionSpace.host + if host.hasSuffix(".ts.net") || host == "localhost" || host.hasSuffix(".tail") { + return (.useCredential, URLCredential(trust: trust)) + } + + return (.performDefaultHandling, nil) + } +} + +/// Shared URLSession that trusts Tailscale hosts. +public let tailscaleURLSession: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + return URLSession(configuration: config, delegate: TailscaleTrustDelegate.shared, delegateQueue: nil) +}() From 8780d881724f51f6bfac787a30fa35c342f9c0b3 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Tue, 28 Apr 2026 10:14:41 +0100 Subject: [PATCH 03/45] fix(client): prevent UI freeze from foreground state wipe + 3 related bugs (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): preserve streaming state when foreground REST returns empty When iOS briefly backgrounds/foregrounds the app, _foreground fires and fetchAndRestoreMessages queries the REST API. If the response is an empty array (timing gap between session creation and event persistence), the previous code wiped all state: messages: [], current: null. This destroyed any in-progress streaming message, causing block_delta events to be silently dropped (messagesReducer returns unchanged state when current is null). The UI appeared frozen until the user sent another message. Fix: preserve existing state when REST returns empty instead of wiping. Co-Authored-By: Claude Opus 4.6 * fix(client): make pendingSend a FIFO queue to prevent message loss Previously pendingSend was a single slot — if the user sent multiple messages while a turn was running, only the last survived. Now it's an array: each session_end dequeues one message, the rest wait their turn. Co-Authored-By: Claude Opus 4.6 * fix(server): persist error-path session_end to EventStore When the query loop exits via the finally block (errors, aborts), the session_end event was sent directly via send()/broadcast(), bypassing EventStore persistence. On reconnect, the event wasn't replayed, leaving the client's running state stuck at true. Now uses sendOrBuffer() so the event is durable and replayed correctly. Co-Authored-By: Claude Opus 4.6 * fix(client): address Centaur review — timer drain, tests, comment - Safety-net timer now reschedules itself to drain remaining queued messages instead of orphaning them after the first flush - Add 4 tests for reconnected running-state handler (not running, running, no session, backward compat) - Add test verifying error-path session_end is persisted to EventStore - Fix misleading comment about "consumed all pending sends" Co-Authored-By: Claude Opus 4.6 * style: run prettier on protocol-parser.test.ts --------- Co-authored-by: Claude Opus 4.6 --- .../client/__tests__/protocol-parser.test.ts | 59 +++++++++++++++-- packages/client/__tests__/store.test.ts | 65 +++++++++++++++++++ packages/client/src/protocol-parser.ts | 20 ++++-- packages/client/src/store.ts | 33 ++++++---- server/__tests__/query-loop.test.ts | 28 ++++++++ server/query-loop.ts | 13 ++-- 6 files changed, 184 insertions(+), 34 deletions(-) diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 23076d8f..63b93fe3 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -5,7 +5,7 @@ import type { ProtocolCallbacks, ProtocolParserState } from '../src/protocol-par function makeState(overrides?: Partial): ProtocolParserState { return { currentSessionId: undefined, - pendingSend: null, + pendingSend: [], ...overrides, }; } @@ -155,14 +155,20 @@ describe('session lifecycle', () => { ]); }); - it('session_end clears pending send and queues it', () => { - const state = makeState({ pendingSend: { type: 'send', prompt: 'follow-up' } }); + it('session_end dequeues first pending send and queues it', () => { + const state = makeState({ + pendingSend: [ + { type: 'send', prompt: 'follow-up' }, + { type: 'send', prompt: 'second' }, + ], + }); const cb = makeCallbacks(); const r = parseServerMessage({ type: 'session_end', sessionId: 'sid' }, state, cb, POOL_KEY); expect(r.messagesActions).toContainEqual({ type: 'SESSION_END', sessionId: 'sid' }); expect(r.messagesActions).toContainEqual({ type: 'SET_RUNNING', running: true }); expect(cb.sendQueued).toHaveBeenCalledWith(POOL_KEY, { type: 'send', prompt: 'follow-up' }); - expect(state.pendingSend).toBeNull(); + // Second message stays queued + expect(state.pendingSend).toEqual([{ type: 'send', prompt: 'second' }]); }); }); @@ -369,10 +375,10 @@ describe('error handling', () => { expect(r.messagesActions).toEqual([{ type: 'ERROR', error: 'Something broke' }]); }); - it('error clears pendingSend', () => { - const state = makeState({ pendingSend: { type: 'send', prompt: 'test' } }); + it('error clears pendingSend queue', () => { + const state = makeState({ pendingSend: [{ type: 'send', prompt: 'test' }] }); parseServerMessage({ type: 'error', error: 'fail' }, state, makeCallbacks(), POOL_KEY); - expect(state.pendingSend).toBeNull(); + expect(state.pendingSend).toEqual([]); }); }); @@ -609,6 +615,45 @@ describe('reconnected', () => { parseServerMessage({ type: 'reconnected', sessions: [] }, makeState(), cb, POOL_KEY); expect(onReconnected).toHaveBeenCalled(); }); + + it('dispatches SET_RUNNING false when active session reports not running', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: false }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toContainEqual({ type: 'SET_RUNNING', running: false }); + }); + + it('does not dispatch SET_RUNNING when active session is still running', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: true }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); + + it('no-ops when no currentSessionId', () => { + const state = makeState({ currentSessionId: undefined }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: false }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); + + it('no-ops when sessions field is undefined (backward compat)', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage({ type: 'reconnected' }, state, makeCallbacks(), POOL_KEY); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); }); // ─── error handling (session expired) ──────────────────────────────────────── diff --git a/packages/client/__tests__/store.test.ts b/packages/client/__tests__/store.test.ts index f83fa1ef..2ba1c5b0 100644 --- a/packages/client/__tests__/store.test.ts +++ b/packages/client/__tests__/store.test.ts @@ -1142,4 +1142,69 @@ describe('foreground recovery', () => { // The original 1 live message persists since RESTORE merges. expect(store.getState().messages.messages).toHaveLength(1); }); + + it('preserves streaming state when REST returns empty array', async () => { + const transport = mockTransport(); + (transport.fetch as ReturnType).mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/messages')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + text: () => Promise.resolve(''), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + text: () => Promise.resolve(''), + }); + }); + const store = createReadyStore(transport); + + store.setState((s) => ({ + sessions: { ...s.sessions, active: 'sess-1' }, + })); + + // Simulate an in-progress streaming message + const streamingCurrent = { + messageId: 'msg-streaming', + blocks: new Map([ + [ + 'b0', + { + blockId: 'b0', + blockType: 'text' as const, + content: 'Let me check the memory vault.', + done: false, + }, + ], + ]), + blockOrder: ['b0'], + }; + store.setState((s) => ({ + messages: { + ...s.messages, + messages: [ + { + messageId: 'user-1', + role: 'user' as const, + timestamp: Date.now(), + blocks: [{ blockId: 'u0', blockType: 'text' as const, content: 'hello' }], + }, + ], + current: streamingCurrent, + }, + })); + + // Foreground fires — REST returns empty (timing gap) + lastWs.simulateMessage({ type: '_foreground' }); + await new Promise((r) => setTimeout(r, 50)); + + // Streaming state must survive — not wiped + const state = store.getState().messages; + expect(state.current).not.toBeNull(); + expect(state.current?.messageId).toBe('msg-streaming'); + expect(state.messages).toHaveLength(1); + expect(state.messages[0].messageId).toBe('user-1'); + }); }); diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 257d63eb..d0b87c04 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -63,8 +63,8 @@ export interface ProtocolParserState { /** Currently tracked session ID (used for expiry detection). */ currentSessionId: string | undefined; - /** Queued message to send after current session ends. */ - pendingSend: Record | null; + /** Queued messages to send after current session ends (FIFO). */ + pendingSend: Record[]; } // ─── Parser result ─────────────────────────────────────────────────────────── @@ -113,10 +113,19 @@ export function parseServerMessage( // ── v2 handshake events ──────────────────────────────────────────────── - case 'reconnected': + case 'reconnected': { result.connectionUpdate = { status: 'connected' }; + // Apply authoritative running state from the server for the active session + const sessions = msg.sessions as Array<{ sessionId: string; running: boolean }> | undefined; + if (sessions && state.currentSessionId) { + const active = sessions.find((s) => s.sessionId === state.currentSessionId); + if (active && !active.running) { + result.messagesActions.push({ type: 'SET_RUNNING', running: false }); + } + } callbacks.onReconnected?.(); break; + } case 'session_takeover': result.messagesActions.push({ type: 'SET_RUNNING', running: false }); @@ -267,9 +276,8 @@ export function parseServerMessage( if (msg.sessionId && !state.currentSessionId) { callbacks.onSessionAssigned(msg.sessionId as string); } - const pending = state.pendingSend; + const pending = state.pendingSend.shift(); if (pending) { - state.pendingSend = null; result.messagesActions.push({ type: 'SET_RUNNING', running: true }); // v2 path: use onSendQueued callback (no pool key needed) if (callbacks.onSendQueued) { @@ -335,7 +343,7 @@ export function parseServerMessage( const errorMsg = msg.error as string; callbacks.setWsRunning?.(poolKey, false); - state.pendingSend = null; + state.pendingSend = []; result.messagesActions.push({ type: 'ERROR', error: errorMsg || 'Unknown error', diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index d9133744..b9fde6f9 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -167,7 +167,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi; } = { currentSessionId: undefined, - pendingSend: null, + pendingSend: [], }; let recoveryInFlight = false; @@ -183,7 +183,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi 0 ? messagesReducer(s.messages, { type: 'RESTORE', messages: msgs }) - : { ...s.messages, messages: [], current: null }, + : s.messages, // preserve state — empty REST response doesn't mean state is invalid })); } }) @@ -266,7 +266,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi { - const pending = parserState.pendingSend; - if (!pending) return; - parserState.pendingSend = null; - parserState.pendingSendTimer = undefined; + parserState.pendingSendTimer = setTimeout(function drainOne() { + const pending = parserState.pendingSend.shift(); + if (!pending) { + parserState.pendingSendTimer = undefined; + return; + } set((s) => ({ messages: messagesReducer(s.messages, { type: 'SET_RUNNING', running: true }), })); connection.send(pending); + // Reschedule for remaining queued messages + if (parserState.pendingSend.length > 0) { + parserState.pendingSendTimer = setTimeout(drainOne, PENDING_SEND_TIMEOUT_MS); + } else { + parserState.pendingSendTimer = undefined; + } }, PENDING_SEND_TIMEOUT_MS); } else { const sent = connection.send(msg); @@ -696,9 +703,9 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi { expect(v2Transport.sent.some((m) => m.type === 'session_end')).toBe(true); }); + it('persists error-path session_end to EventStore via sendOrBuffer', async () => { + const store = new EventStore(':memory:'); + const connRegistry = new ConnectionRegistry(); + const v2Transport = fakeTransport(); + connRegistry.register(clientId, v2Transport); + connRegistry.watch(clientId, 'sess-err'); + connRegistry.setActive(clientId, 'sess-err'); + + const session = registry.get(clientId)!; + session.sessionId = 'sess-err'; + + async function* errorStream() { + yield { + type: 'stream_event', + event: { type: 'message_start', message: { id: 'msg-err' } }, + }; + throw new Error('simulated failure'); + } + + await runQueryLoop(errorStream(), clientId, registry, abortController, store, 'sess-err', { + connRegistry, + }); + + // session_end must be persisted to EventStore for reconnect replay + const events = store.getEventsAfter('sess-err', 0); + expect(events.some((e) => e.type === 'session_end')).toBe(true); + }); + it('delivers events to a NEW connection after WS reconnect (old connection gone)', async () => { const connRegistry = new ConnectionRegistry(); const oldTransport = fakeTransport(); diff --git a/server/query-loop.ts b/server/query-loop.ts index b32989a7..ea54dacc 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -819,14 +819,11 @@ async function _runQueryLoopInner( if (finalSession) { finalSession.currentSnapshot = null; if (!doneSent) { - const endMsg = v2('session_end', { sessionId: finalSession.sessionId }); - const sid = finalSession.sessionId; - if (sid && connRegistry?.hasOpenWatchers(sid)) { - connRegistry.broadcast(sid, endMsg); - } else { - send(finalSession.transport, endMsg); - broadcastToObservers(finalSession.observers, endMsg); - } + // Use sendOrBuffer to persist to EventStore (not just send) so that + // reconnect replay includes session_end and clears stale running state. + const sid = finalSession.sessionId ?? resolvedSessionId; + const endMsg = v2('session_end', { sessionId: sid }); + sendOrBuffer(endMsg, clientId, registry, store, sid, connRegistry); if (sid && connRegistry?.hasOpenWatchers(sid)) { // Clear active session only on connections whose active is this session for (const { connectionId: cid } of connRegistry.getConnectionsWatching(sid, true)) { From 932d1df5a1110986e43675a2e8f4d68b569b0ec8 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Tue, 28 Apr 2026 23:25:55 +0100 Subject: [PATCH 04/45] feat(workload): WorkloadStore + WorkSignal intake API (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workload): add WorkloadStore with WorkSignal intake + REST API Phase 1 of the workload orchestration package — unified intake layer for the Telos → Task Board pipeline: - WorkloadStore: SQLite-backed todo_items + todo_sources tables, shared DB with TaskStore. Ingest, dedup, score, CRUD, promote-to-goal. - WorkSignal Zod schema: standardized intake contract for fetchers. - REST endpoints: POST /api/workload/signals (single + batch), GET/PATCH/DELETE /api/workload/items, POST .../promote. - 24 tests covering ingest, dedup, scoring, filtering, lifecycle sync. Co-Authored-By: Claude Opus 4.6 * feat(workload): Phase 2 backend — lifecycle sync + WebSocket events - task-orchestrator: wire WorkloadStore, call completeByGoal() on goal completion - app.ts: add setWorkloadBroadcast() and emit events on item create/update/promote - index.ts: wire workloadStore into orchestrator deps, broadcast WS events Part of workload orchestration Phase 2 (dimakis/mitzo#293, dimakis/mgmt#74). Co-Authored-By: Claude Sonnet 4.5 * fix(workload): address PR #293 review warnings - Fix timestamp validation: add .datetime() to WorkSignalBody schema and NaN check in ingest() - Fix URL validation: use .url() instead of .min(1) for WorkSignalBody.url - Fix EMPTY_HINTS mutability: Object.freeze() to prevent accidental mutation via shallow copies - Fix JSDoc: clarify that cross-source attachment path is not yet implemented - Remove dead code: drop specMode from WorkloadPromoteBody schema (unused) - Remove dead param: drop existingSourceCount from computeUrgency (crossSourceBoost was always 0) - Add integration tests: comprehensive route tests for all 7 workload API endpoints All yellow warnings from Centaur review now resolved. Co-Authored-By: Claude Sonnet 4.5 * test(workload): add unit test for invalid timestamp rejection in ingest() Co-Authored-By: Claude Opus 4.6 * style(workload): format workload-routes test with prettier Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- server/__tests__/workload-routes.test.ts | 520 +++++++++++++++++++++++ server/__tests__/workload-store.test.ts | 275 ++++++++++++ server/api-schemas.ts | 43 ++ server/app.ts | 132 ++++++ server/index.ts | 12 + server/task-orchestrator.ts | 9 + server/workload-store.ts | 512 ++++++++++++++++++++++ 7 files changed, 1503 insertions(+) create mode 100644 server/__tests__/workload-routes.test.ts create mode 100644 server/__tests__/workload-store.test.ts create mode 100644 server/workload-store.ts diff --git a/server/__tests__/workload-routes.test.ts b/server/__tests__/workload-routes.test.ts new file mode 100644 index 00000000..c599c7ce --- /dev/null +++ b/server/__tests__/workload-routes.test.ts @@ -0,0 +1,520 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import type { Express } from 'express'; +import request from 'supertest'; +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const TEST_REPO = join(tmpdir(), `mitzo-workload-routes-test-${process.pid}`); + +vi.mock('../chat.js', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { join: pjoin } = require('path'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { tmpdir: ptmpdir } = require('os'); + const repo = pjoin(ptmpdir(), `mitzo-workload-routes-test-${process.pid}`); + return { + getSessions: vi.fn().mockResolvedValue({ sessions: [], hasMore: false }), + getMessages: vi.fn().mockResolvedValue([]), + renameSessionById: vi.fn().mockResolvedValue(undefined), + hideSession: vi.fn(), + hideAllSessions: vi.fn(), + BASE_REPO: repo, + getRepoConfig: vi.fn(() => ({ + quickActions: [], + allowedPaths: [], + roots: [{ label: 'Main', path: repo }], + resolvedVenvPaths: [], + toolTierOverrides: {}, + inboxPath: 'mgmt_lib/inbox', + resolvedInboxPath: pjoin(repo, 'mgmt_lib/inbox'), + repos: {}, + contextBlocks: {}, + })), + getMcpServerNames: vi.fn().mockReturnValue([]), + AVAILABLE_MODELS: [{ id: 'test-model', label: 'Test', desc: 'Test model' }], + registry: { get: vi.fn() }, + eventStore: { getEventsAfter: vi.fn().mockReturnValue([]) }, + setTaskStore: vi.fn(), + }; +}); + +vi.mock('../permissions.js', () => ({ + resolvePending: vi.fn().mockReturnValue(true), +})); + +vi.mock('../worktree.js', () => ({ + listWorktrees: vi.fn().mockReturnValue([]), + cleanupStaleWorktrees: vi.fn(), +})); + +vi.mock('../git-version.js', () => ({ + getLocalCommit: vi.fn().mockReturnValue('abc1234'), + isUpdateAvailable: vi.fn().mockReturnValue(false), +})); + +let app: Express; +let authCookie: string; + +async function getAuthCookie(agent: request.Agent): Promise { + const res = await agent.post('/api/auth/login').send({ passphrase: process.env.AUTH_PASSPHRASE }); + const setCookie = res.headers['set-cookie']; + if (!setCookie) throw new Error('No cookie returned from login'); + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + return cookies[0].split(';')[0]; +} + +beforeAll(async () => { + mkdirSync(TEST_REPO, { recursive: true }); + mkdirSync(join(TEST_REPO, 'mgmt_lib', 'inbox', 'archive'), { recursive: true }); + mkdirSync(join(TEST_REPO, '.mitzo'), { recursive: true }); + + const mod = await import('../app.js'); + app = mod.app; + + const agent = request(app); + authCookie = await getAuthCookie(agent); +}); + +afterAll(async () => { + // Clean up stores + try { + const mod = await import('../app.js'); + if (mod.taskStore?.close) mod.taskStore.close(); + if (mod.workloadStore?.close) mod.workloadStore.close(); + } catch { + // ignore + } + try { + rmSync(TEST_REPO, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +describe('workload routes', () => { + // --- Auth --- + + it('POST /api/workload/signals — unauthenticated returns 401', async () => { + const res = await request(app).post('/api/workload/signals').send({ + sourceType: 'test', + sourceId: '123', + url: 'https://example.com', + title: 'Test', + author: 'Test Author', + timestamp: new Date().toISOString(), + }); + expect(res.status).toBe(401); + }); + + it('GET /api/workload/items — unauthenticated returns 401', async () => { + const res = await request(app).get('/api/workload/items'); + expect(res.status).toBe(401); + }); + + // --- POST /api/workload/signals --- + + it('POST /api/workload/signals — creates new item and source', async () => { + const signal = { + sourceType: 'github', + sourceId: 'pr-123', + url: 'https://github.com/org/repo/pull/123', + title: 'Fix critical bug', + snippet: 'This PR fixes a critical issue', + author: 'alice', + timestamp: new Date().toISOString(), + profile: 'default', + urgencyHint: 0.8, + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + created: true, + item: { + title: signal.title, + snippet: signal.snippet, + status: 'active', + profile: 'default', + starred: false, + }, + }); + expect(res.body.item.sources).toHaveLength(1); + expect(res.body.item.sources[0]).toMatchObject({ + sourceType: 'github', + sourceId: 'pr-123', + title: signal.title, + }); + }); + + it('POST /api/workload/signals — deduplicates by (sourceType, sourceId)', async () => { + const signal = { + sourceType: 'jira', + sourceId: 'PROJ-456', + url: 'https://jira.example.com/browse/PROJ-456', + title: 'Implement feature', + author: 'bob', + timestamp: new Date().toISOString(), + }; + + // First call creates + const res1 = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + expect(res1.status).toBe(201); + expect(res1.body.created).toBe(true); + + // Second call with same sourceType + sourceId returns existing + const res2 = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send({ ...signal, timestamp: new Date().toISOString() }); + expect(res2.status).toBe(200); + expect(res2.body.created).toBe(false); + expect(res2.body.item.id).toBe(res1.body.item.id); + }); + + it('POST /api/workload/signals — validates timestamp format', async () => { + const signal = { + sourceType: 'test', + sourceId: 'invalid-ts', + url: 'https://example.com', + title: 'Test', + author: 'Test', + timestamp: 'not-a-valid-date', + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(400); + }); + + it('POST /api/workload/signals — validates URL format', async () => { + const signal = { + sourceType: 'test', + sourceId: 'bad-url', + url: 'not a url', + title: 'Test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(400); + }); + + it('POST /api/workload/signals — merges context hints', async () => { + const signal = { + sourceType: 'test', + sourceId: 'hints-test', + url: 'https://example.com', + title: 'Test hints', + author: 'Test', + timestamp: new Date().toISOString(), + contextHints: { + repos: ['repo1', 'repo2'], + keywords: ['urgent'], + taskHint: 'Review and merge', + }, + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(201); + expect(res.body.item.contextHints).toMatchObject({ + repos: ['repo1', 'repo2'], + keywords: ['urgent'], + taskHint: 'Review and merge', + }); + }); + + // --- POST /api/workload/signals/batch --- + + it('POST /api/workload/signals/batch — ingests multiple signals', async () => { + const signals = [ + { + sourceType: 'batch-test', + sourceId: 'batch-1', + url: 'https://example.com/1', + title: 'Batch item 1', + author: 'Test', + timestamp: new Date().toISOString(), + }, + { + sourceType: 'batch-test', + sourceId: 'batch-2', + url: 'https://example.com/2', + title: 'Batch item 2', + author: 'Test', + timestamp: new Date().toISOString(), + }, + ]; + + const res = await request(app) + .post('/api/workload/signals/batch') + .set('Cookie', authCookie) + .send({ signals }); + + expect(res.status).toBe(201); + expect(res.body.items).toHaveLength(2); + expect(res.body.created).toBe(2); + expect(res.body.total).toBe(2); + }); + + it('POST /api/workload/signals/batch — validates max batch size', async () => { + const signals = Array.from({ length: 101 }, (_, i) => ({ + sourceType: 'batch', + sourceId: `batch-${i}`, + url: `https://example.com/${i}`, + title: `Item ${i}`, + author: 'Test', + timestamp: new Date().toISOString(), + })); + + const res = await request(app) + .post('/api/workload/signals/batch') + .set('Cookie', authCookie) + .send({ signals }); + + expect(res.status).toBe(400); + }); + + // --- GET /api/workload/items --- + + it('GET /api/workload/items — lists all items', async () => { + const res = await request(app).get('/api/workload/items').set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('items'); + expect(res.body).toHaveProperty('profiles'); + expect(Array.isArray(res.body.items)).toBe(true); + }); + + it('GET /api/workload/items — filters by status', async () => { + // Create item and complete it + const signal = { + sourceType: 'status-test', + sourceId: 'st-1', + url: 'https://example.com', + title: 'Status test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ status: 'completed' }); + + const res = await request(app) + .get('/api/workload/items?status=completed') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.items.some((item: { id: string }) => item.id === itemId)).toBe(true); + }); + + // --- GET /api/workload/items/:id --- + + it('GET /api/workload/items/:id — returns item by ID', async () => { + const signal = { + sourceType: 'get-test', + sourceId: 'gt-1', + url: 'https://example.com', + title: 'Get test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app).get(`/api/workload/items/${itemId}`).set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.item.id).toBe(itemId); + }); + + it('GET /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .get('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + }); + + // --- PATCH /api/workload/items/:id --- + + it('PATCH /api/workload/items/:id — updates item fields', async () => { + const signal = { + sourceType: 'update-test', + sourceId: 'ut-1', + url: 'https://example.com', + title: 'Update test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ + title: 'Updated title', + starred: true, + urgency: 0.9, + }); + + expect(res.status).toBe(200); + expect(res.body.item).toMatchObject({ + id: itemId, + title: 'Updated title', + starred: true, + urgency: 0.9, + }); + }); + + it('PATCH /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .patch('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie) + .send({ title: 'New title' }); + + expect(res.status).toBe(404); + }); + + it('PATCH /api/workload/items/:id — validates input', async () => { + const signal = { + sourceType: 'validation-test', + sourceId: 'vt-1', + url: 'https://example.com', + title: 'Validation test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ urgency: 2.0 }); // Invalid: > 1.0 + + expect(res.status).toBe(400); + }); + + // --- DELETE /api/workload/items/:id --- + + it('DELETE /api/workload/items/:id — deletes item', async () => { + const signal = { + sourceType: 'delete-test', + sourceId: 'dt-1', + url: 'https://example.com', + title: 'Delete test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .delete(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + + // Verify deletion + const getRes = await request(app) + .get(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie); + expect(getRes.status).toBe(404); + }); + + it('DELETE /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .delete('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + }); + + // --- POST /api/workload/items/:id/promote --- + + it('POST /api/workload/items/:id/promote — creates task from item', async () => { + const signal = { + sourceType: 'promote-test', + sourceId: 'pt-1', + url: 'https://example.com', + title: 'Promote test', + snippet: 'This should become a task', + author: 'Test', + timestamp: new Date().toISOString(), + contextHints: { + repos: ['test-repo'], + taskHint: 'Review carefully', + }, + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .post(`/api/workload/items/${itemId}/promote`) + .set('Cookie', authCookie) + .send({ description: 'Additional context' }); + + expect(res.status).toBe(201); + expect(res.body.task).toMatchObject({ + title: signal.title, + status: 'pending', + }); + expect(res.body.task.description).toContain('Additional context'); + expect(res.body.task.description).toContain('Review carefully'); + expect(res.body.item.status).toBe('acknowledged'); + expect(res.body.item.goalId).toBe(res.body.task.id); + }); + + it('POST /api/workload/items/:id/promote — returns 404 for missing item', async () => { + const res = await request(app) + .post('/api/workload/items/nonexistent-id/promote') + .set('Cookie', authCookie) + .send({}); + + expect(res.status).toBe(404); + }); +}); diff --git a/server/__tests__/workload-store.test.ts b/server/__tests__/workload-store.test.ts new file mode 100644 index 00000000..93a183eb --- /dev/null +++ b/server/__tests__/workload-store.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import Database from 'better-sqlite3'; +import { WorkloadStore } from '../workload-store.js'; +import type { WorkSignal } from '../workload-store.js'; + +const TEST_DIR = join(tmpdir(), `mitzo-workload-test-${process.pid}`); + +let db: Database.Database; +let store: WorkloadStore; + +function makeSignal(overrides?: Partial): WorkSignal { + return { + sourceType: 'manual', + sourceId: `test-${Date.now()}-${Math.random()}`, + url: 'https://example.com', + title: 'Test item', + snippet: 'A test work signal', + author: 'test-user', + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + db = new Database(join(TEST_DIR, `workload-${Date.now()}.db`)); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + store = new WorkloadStore(db); +}); + +afterEach(() => { + store.close(); + db.close(); + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +describe('WorkloadStore', () => { + describe('ingest', () => { + it('creates a new item from a signal', () => { + const signal = makeSignal({ title: 'Review PR #42' }); + const { item, created } = store.ingest(signal); + + expect(created).toBe(true); + expect(item.title).toBe('Review PR #42'); + expect(item.status).toBe('active'); + expect(item.sources).toHaveLength(1); + expect(item.sources[0].sourceType).toBe('manual'); + expect(item.sources[0].sourceId).toBe(signal.sourceId); + }); + + it('deduplicates by sourceType + sourceId', () => { + const signal = makeSignal({ sourceType: 'jira', sourceId: 'RHAIENG-100' }); + const first = store.ingest(signal); + const second = store.ingest(signal); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(first.item.id).toBe(second.item.id); + }); + + it('applies urgency hint', () => { + const signal = makeSignal({ urgencyHint: 0.8 }); + const { item } = store.ingest(signal); + expect(item.urgency).toBeGreaterThanOrEqual(0.8); + }); + + it('applies age boost for old signals', () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + const signal = makeSignal({ + timestamp: oldDate.toISOString(), + urgencyHint: 0.3, + }); + const { item } = store.ingest(signal); + // Age > 7 days = +0.1 boost + expect(item.urgency).toBeGreaterThanOrEqual(0.4); + }); + + it('stores context hints', () => { + const signal = makeSignal({ + contextHints: { + repos: ['dimakis/mgmt'], + jiraKeys: ['RHAIENG-100'], + keywords: ['auth', 'refactor'], + }, + }); + const { item } = store.ingest(signal); + expect(item.contextHints.repos).toEqual(['dimakis/mgmt']); + expect(item.contextHints.jiraKeys).toEqual(['RHAIENG-100']); + expect(item.contextHints.keywords).toEqual(['auth', 'refactor']); + }); + + it('respects profile from signal', () => { + const signal = makeSignal({ profile: 'work' }); + const { item } = store.ingest(signal); + expect(item.profile).toBe('work'); + }); + + it('defaults profile to "default"', () => { + const signal = makeSignal(); + const { item } = store.ingest(signal); + expect(item.profile).toBe('default'); + }); + + it('throws on invalid timestamp format', () => { + const signal = makeSignal({ timestamp: 'not-a-date' }); + expect(() => store.ingest(signal)).toThrow('Invalid timestamp format'); + }); + }); + + describe('ingestBatch', () => { + it('ingests multiple signals in a transaction', () => { + const signals = [ + makeSignal({ title: 'Item 1', sourceId: 'batch-1' }), + makeSignal({ title: 'Item 2', sourceId: 'batch-2' }), + makeSignal({ title: 'Item 3', sourceId: 'batch-3' }), + ]; + const result = store.ingestBatch(signals); + expect(result.items).toHaveLength(3); + expect(result.created).toBe(3); + }); + + it('deduplicates within batch', () => { + const signals = [ + makeSignal({ sourceId: 'same-id', title: 'First' }), + makeSignal({ sourceId: 'same-id', title: 'Duplicate' }), + ]; + const result = store.ingestBatch(signals); + expect(result.items).toHaveLength(2); + expect(result.created).toBe(1); // second is a dedup + }); + }); + + describe('list', () => { + it('returns items sorted by starred then urgency', () => { + store.ingest(makeSignal({ title: 'Low', urgencyHint: 0.1, sourceId: 'low' })); + store.ingest(makeSignal({ title: 'High', urgencyHint: 0.9, sourceId: 'high' })); + + const items = store.list(); + expect(items).toHaveLength(2); + expect(items[0].title).toBe('High'); + expect(items[1].title).toBe('Low'); + }); + + it('filters by profile', () => { + store.ingest(makeSignal({ profile: 'work', sourceId: 'w1' })); + store.ingest(makeSignal({ profile: 'personal', sourceId: 'p1' })); + + const work = store.list({ profile: 'work' }); + expect(work).toHaveLength(1); + expect(work[0].profile).toBe('work'); + }); + + it('filters by status', () => { + const { item } = store.ingest(makeSignal({ sourceId: 'ack-me' })); + store.update(item.id, { status: 'acknowledged' }); + store.ingest(makeSignal({ sourceId: 'active-one' })); + + const active = store.list({ status: 'active' }); + expect(active).toHaveLength(1); + }); + + it('filters by starred', () => { + const { item } = store.ingest(makeSignal({ sourceId: 'star-me' })); + store.update(item.id, { starred: true }); + store.ingest(makeSignal({ sourceId: 'unstarred' })); + + const starred = store.list({ starred: true }); + expect(starred).toHaveLength(1); + expect(starred[0].starred).toBe(true); + }); + }); + + describe('update', () => { + it('updates status', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { status: 'acknowledged' }); + expect(updated?.status).toBe('acknowledged'); + }); + + it('updates starred', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { starred: true }); + expect(updated?.starred).toBe(true); + }); + + it('sets snoozed status when snoozedUntil is set', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { snoozedUntil: '2026-05-01' }); + expect(updated?.status).toBe('snoozed'); + expect(updated?.snoozedUntil).toBe('2026-05-01'); + }); + + it('merges context hints on update', () => { + const { item } = store.ingest( + makeSignal({ contextHints: { repos: ['repo-a'], keywords: ['old'] } }), + ); + const updated = store.update(item.id, { + contextHints: { repos: ['repo-b'], keywords: ['new'] }, + }); + expect(updated?.contextHints.repos).toEqual(['repo-a', 'repo-b']); + expect(updated?.contextHints.keywords).toEqual(['old', 'new']); + }); + + it('returns null for non-existent item', () => { + const result = store.update('nonexistent', { status: 'completed' }); + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('deletes an item and its sources', () => { + const { item } = store.ingest(makeSignal()); + expect(store.delete(item.id)).toBe(true); + expect(store.get(item.id)).toBeNull(); + }); + + it('returns false for non-existent item', () => { + expect(store.delete('nonexistent')).toBe(false); + }); + }); + + describe('setGoalId', () => { + it('links item to goal and sets acknowledged', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.setGoalId(item.id, 'goal-123'); + expect(updated?.goalId).toBe('goal-123'); + expect(updated?.status).toBe('acknowledged'); + }); + }); + + describe('completeByGoal', () => { + it('completes items linked to a goal', () => { + const { item } = store.ingest(makeSignal()); + store.setGoalId(item.id, 'goal-456'); + store.completeByGoal('goal-456'); + const completed = store.get(item.id); + expect(completed?.status).toBe('completed'); + }); + }); + + describe('unsnoozeDue', () => { + it('unsnoozes items past their snooze date', () => { + const { item } = store.ingest(makeSignal()); + store.update(item.id, { snoozedUntil: '2020-01-01' }); // past date + const count = store.unsnoozeDue(); + expect(count).toBe(1); + const updated = store.get(item.id); + expect(updated?.status).toBe('active'); + expect(updated?.snoozedUntil).toBeNull(); + }); + }); + + describe('profiles', () => { + it('returns profile counts excluding completed', () => { + store.ingest(makeSignal({ profile: 'work', sourceId: 'w1' })); + store.ingest(makeSignal({ profile: 'work', sourceId: 'w2' })); + store.ingest(makeSignal({ profile: 'personal', sourceId: 'p1' })); + + const profiles = store.profiles(); + expect(profiles).toEqual([ + { profile: 'work', count: 2 }, + { profile: 'personal', count: 1 }, + ]); + }); + }); +}); diff --git a/server/api-schemas.ts b/server/api-schemas.ts index ac109e32..c17e3faf 100644 --- a/server/api-schemas.ts +++ b/server/api-schemas.ts @@ -182,3 +182,46 @@ export const SignalBody = z.object({ status: z.enum(['pass', 'fail']), artifacts: z.record(z.string(), z.unknown()).optional(), }); + +// -- Workload schemas -- + +const WorkSignalContextHints = z.object({ + repos: z.array(z.string()).optional(), + paths: z.array(z.string()).optional(), + issues: z.array(z.string()).optional(), + docIds: z.array(z.string()).optional(), + people: z.array(z.string()).optional(), + jiraKeys: z.array(z.string()).optional(), + keywords: z.array(z.string()).optional(), + taskHint: z.string().optional(), +}); + +export const WorkSignalBody = z.object({ + sourceType: z.string().min(1), + sourceId: z.string().min(1), + url: z.string().url(), + title: z.string().min(1), + snippet: z.string().default(''), + author: z.string().min(1), + timestamp: z.string().datetime({ offset: true }), + contextHints: WorkSignalContextHints.optional(), + urgencyHint: z.number().min(0).max(1).optional(), + profile: z.string().optional(), +}); + +export const WorkSignalBatchBody = z.object({ + signals: z.array(WorkSignalBody).min(1).max(100), +}); + +export const WorkloadItemUpdateBody = z.object({ + title: z.string().optional(), + status: z.enum(['active', 'acknowledged', 'snoozed', 'completed']).optional(), + starred: z.boolean().optional(), + snoozedUntil: z.string().nullable().optional(), + urgency: z.number().min(0).max(1).optional(), + contextHints: WorkSignalContextHints.optional(), +}); + +export const WorkloadPromoteBody = z.object({ + description: z.string().optional(), +}); diff --git a/server/app.ts b/server/app.ts index 1b34a59b..0e97e0a3 100644 --- a/server/app.ts +++ b/server/app.ts @@ -60,6 +60,10 @@ import { WorkflowInstantiateBody, TemplateCreateBody, SignalBody, + WorkSignalBody, + WorkSignalBatchBody, + WorkloadItemUpdateBody, + WorkloadPromoteBody, } from './api-schemas.js'; import type { TaskOrchestrator } from './task-orchestrator.js'; import type { WorkflowTemplateStore, TemplateCreateInput } from './workflow-templates.js'; @@ -78,6 +82,7 @@ import { SkillRegistry } from './skills.js'; import { mkdirSync } from 'fs'; import { homedir } from 'os'; import { TaskStore, type TaskCreateInput, type TaskUpdateInput } from './task-store.js'; +import { WorkloadStore, type WorkSignal, type TodoItemUpdateInput } from './workload-store.js'; const log = createLogger('server'); @@ -202,6 +207,7 @@ let updateAvailable = false; let onUpdateAvailable: (() => void) | null = null; let onInboxUpdated: (() => void) | null = null; let onTaskBroadcast: ((event: Record) => void) | null = null; +let onWorkloadBroadcast: ((event: Record) => void) | null = null; let orchestrator: TaskOrchestrator | null = null; let templateStore: WorkflowTemplateStore | null = null; let signalProcessor: SignalProcessor | null = null; @@ -228,6 +234,7 @@ try { } export const taskStore = new TaskStore(join(mitzoDir, 'tasks.db')); setTaskStore(taskStore); +export const workloadStore = new WorkloadStore(taskStore.getDatabase()); setTokenStorePath(join(mitzoDir, 'device-tokens.json')); export function setUpdateBroadcast(fn: () => void) { @@ -242,6 +249,10 @@ export function setTaskBroadcast(fn: (event: Record) => void) { onTaskBroadcast = fn; } +export function setWorkloadBroadcast(fn: (event: Record) => void) { + onWorkloadBroadcast = fn; +} + /** Broadcast inbox_updated to all connected WS clients. */ export function broadcastInboxUpdate() { onInboxUpdated?.(); @@ -1505,6 +1516,127 @@ app.post('/api/todos/:id/action', async (req, res) => { } }); +// --- Workload API --- + +app.post('/api/workload/signals', (req, res) => { + const body = WorkSignalBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid signal' }); + return; + } + const result = workloadStore.ingest(body.data as WorkSignal); + res.status(result.created ? 201 : 200).json({ item: result.item, created: result.created }); + + // Broadcast workload item change + const eventType = result.created ? 'workload_item_created' : 'workload_item_updated'; + onWorkloadBroadcast?.({ type: eventType, item: result.item }); +}); + +app.post('/api/workload/signals/batch', (req, res) => { + const body = WorkSignalBatchBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid batch' }); + return; + } + const result = workloadStore.ingestBatch(body.data.signals as WorkSignal[]); + res + .status(201) + .json({ items: result.items, created: result.created, total: result.items.length }); + + // Broadcast batch workload changes + if (result.items.length > 0) { + onWorkloadBroadcast?.({ + type: 'workload_batch_updated', + items: result.items, + created: result.created, + }); + } +}); + +app.get('/api/workload/items', (req, res) => { + const profile = req.query.profile as string | undefined; + const status = req.query.status as string | undefined; + const starred = req.query.starred === 'true' ? true : undefined; + const items = workloadStore.list({ + profile, + status: status as 'active' | 'acknowledged' | 'snoozed' | 'completed' | undefined, + starred, + }); + const profiles = workloadStore.profiles(); + res.json({ items, profiles }); +}); + +app.get('/api/workload/items/:id', (req, res) => { + const item = workloadStore.get(req.params.id); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ item }); +}); + +app.patch('/api/workload/items/:id', (req, res) => { + const body = WorkloadItemUpdateBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid update' }); + return; + } + const item = workloadStore.update(req.params.id, body.data as TodoItemUpdateInput); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ item }); + onWorkloadBroadcast?.({ type: 'workload_item_updated', item }); +}); + +app.delete('/api/workload/items/:id', (req, res) => { + const ok = workloadStore.delete(req.params.id); + if (!ok) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ ok: true }); +}); + +app.post('/api/workload/items/:id/promote', (req, res) => { + const body = WorkloadPromoteBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid promote body' }); + return; + } + + const item = workloadStore.get(req.params.id); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + + // Build description from item context + const descParts: string[] = []; + if (body.data.description) descParts.push(body.data.description); + if (item.contextHints.taskHint) descParts.push(item.contextHints.taskHint); + const hintsWithValues = Object.entries(item.contextHints) + .filter(([k, v]) => k !== 'taskHint' && Array.isArray(v) && v.length > 0) + .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`); + if (hintsWithValues.length > 0) descParts.push(hintsWithValues.join('\n')); + + // Create root task (goal) from item + const task = taskStore.create({ + title: item.title, + description: descParts.join('\n\n') || undefined, + annotations: item.sources.map((s) => `Source: [${s.sourceType}] ${s.title} — ${s.url}`), + }); + + // Link item to goal + workloadStore.setGoalId(item.id, task.id); + + const updatedItem = workloadStore.get(item.id); + res.status(201).json({ task, item: updatedItem }); + onTaskBroadcast?.({ type: 'task_state', tasks: taskStore.getTree() }); + onWorkloadBroadcast?.({ type: 'workload_item_updated', item: updatedItem }); +}); + // --- Static files --- const frontendDist = join(__dirname, '..', 'frontend', 'dist'); diff --git a/server/index.ts b/server/index.ts index c77ce299..d88ba3d6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -45,6 +45,7 @@ import { setUpdateBroadcast, setInboxBroadcast, setTaskBroadcast, + setWorkloadBroadcast, setOrchestrator, setTemplateStore, setSignalProcessor, @@ -54,6 +55,7 @@ import { isAllowedPath, yapperWsProxy, taskStore, + workloadStore, } from './app.js'; import { WorkflowTemplateStore, seedBuiltInTemplates } from './workflow-templates.js'; import { SignalProcessor } from './signal-processor.js'; @@ -123,6 +125,15 @@ setTaskBroadcast((event) => { connRegistry.broadcastAll(event as Record); }); +setWorkloadBroadcast((event) => { + const msg = JSON.stringify(event); + wss.clients.forEach((client) => { + if (v2Sockets.has(client)) return; + if (client.readyState === client.OPEN) client.send(msg); + }); + connRegistry.broadcastAll(event as Record); +}); + // --- Workflow layer --- const wfTemplateStore = new WorkflowTemplateStore(taskStore.getDatabase()); seedBuiltInTemplates(wfTemplateStore); @@ -139,6 +150,7 @@ setSignalProcessor(signalProc); // --- Task Orchestrator --- const orchestrator = new TaskOrchestrator({ store: taskStore, + workloadStore, watchSignal: (taskId, gateConfig) => signalProc.watch(taskId, gateConfig), getClientId: () => { // Find the first registered client (reuse-only for Phase 2) diff --git a/server/task-orchestrator.ts b/server/task-orchestrator.ts index a6c36554..05c5fc8c 100644 --- a/server/task-orchestrator.ts +++ b/server/task-orchestrator.ts @@ -1,4 +1,5 @@ import type { TaskStore, Task, GateConfig } from './task-store.js'; +import type { WorkloadStore } from './workload-store.js'; import { sendToChat } from './chat.js'; import { createLogger } from './logger.js'; @@ -21,6 +22,7 @@ export interface StartOptions { export interface OrchestratorDeps { store: TaskStore; + workloadStore?: WorkloadStore; /** Resolve session's clientId for the reuse session */ getClientId: () => string | null; /** Set task context on the session */ @@ -274,6 +276,13 @@ export class TaskOrchestrator { goalId: this.goalId, status: goalStatus, }); + + // Lifecycle sync: complete linked TodoItems when goal completes + if (goalStatus === 'done' && this.deps.workloadStore) { + this.deps.workloadStore.completeByGoal(this.goalId); + log.info('completed linked workload items', { goalId: this.goalId }); + } + this.stop(); return; } diff --git a/server/workload-store.ts b/server/workload-store.ts new file mode 100644 index 00000000..54b0315e --- /dev/null +++ b/server/workload-store.ts @@ -0,0 +1,512 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; +import { createLogger } from './logger.js'; + +const log = createLogger('workload-store'); + +// --- Types --- + +export type TodoStatus = 'active' | 'acknowledged' | 'snoozed' | 'completed'; + +export interface ContextHints { + repos: string[]; + paths: string[]; + issues: string[]; + docIds: string[]; + people: string[]; + jiraKeys: string[]; + keywords: string[]; + taskHint: string; +} + +export interface TodoSource { + id: string; + itemId: string; + sourceType: string; + sourceId: string; + url: string; + title: string; + author: string; + timestamp: number; + snippet: string; +} + +export interface TodoItem { + id: string; + title: string; + snippet: string | null; + status: TodoStatus; + profile: string; + urgency: number; + starred: boolean; + snoozedUntil: string | null; + contextHints: ContextHints; + clusterId: string | null; + goalId: string | null; + sources: TodoSource[]; + createdAt: number; + updatedAt: number; +} + +export interface WorkSignal { + sourceType: string; + sourceId: string; + url: string; + title: string; + snippet: string; + author: string; + timestamp: string; // ISO 8601 + contextHints?: Partial; + urgencyHint?: number; + profile?: string; +} + +export interface TodoItemUpdateInput { + title?: string; + status?: TodoStatus; + starred?: boolean; + snoozedUntil?: string | null; + urgency?: number; + contextHints?: Partial; +} + +// --- DB row types --- + +interface TodoItemRow { + id: string; + title: string; + snippet: string | null; + status: string; + profile: string; + urgency: number; + starred: number; + snoozed_until: string | null; + context_hints: string | null; + cluster_id: string | null; + goal_id: string | null; + created_at: number; + updated_at: number; +} + +interface TodoSourceRow { + id: string; + item_id: string; + source_type: string; + source_id: string; + url: string; + title: string; + author: string; + timestamp: number; + snippet: string | null; +} + +// --- Schema --- + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS todo_items ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + snippet TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','acknowledged','snoozed','completed')), + profile TEXT NOT NULL DEFAULT 'default', + urgency REAL NOT NULL DEFAULT 0.0, + starred INTEGER NOT NULL DEFAULT 0, + snoozed_until TEXT, + context_hints TEXT, + cluster_id TEXT, + goal_id TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS todo_sources ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT NOT NULL, + timestamp REAL NOT NULL, + snippet TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_todo_profile ON todo_items(profile); + CREATE INDEX IF NOT EXISTS idx_todo_status ON todo_items(status); + CREATE INDEX IF NOT EXISTS idx_todo_sources_item ON todo_sources(item_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_todo_sources_dedup ON todo_sources(source_type, source_id); +`; + +// --- Helpers --- + +/** Sentinel for missing context hints. Frozen to prevent accidental mutation via shallow copies. */ +const EMPTY_HINTS: ContextHints = Object.freeze({ + repos: [], + paths: [], + issues: [], + docIds: [], + people: [], + jiraKeys: [], + keywords: [], + taskHint: '', +}) as ContextHints; + +function parseContextHints(raw: string | null): ContextHints { + if (!raw) return { ...EMPTY_HINTS }; + try { + const parsed = JSON.parse(raw); + return { ...EMPTY_HINTS, ...parsed }; + } catch { + return { ...EMPTY_HINTS }; + } +} + +function mergeContextHints(existing: ContextHints, incoming: Partial): ContextHints { + const dedup = (a: string[], b: string[] | undefined) => [...new Set([...a, ...(b ?? [])])]; + return { + repos: dedup(existing.repos, incoming.repos), + paths: dedup(existing.paths, incoming.paths), + issues: dedup(existing.issues, incoming.issues), + docIds: dedup(existing.docIds, incoming.docIds), + people: dedup(existing.people, incoming.people), + jiraKeys: dedup(existing.jiraKeys, incoming.jiraKeys), + keywords: dedup(existing.keywords, incoming.keywords), + taskHint: incoming.taskHint || existing.taskHint, + }; +} + +function rowToItem(row: TodoItemRow, sources: TodoSource[]): TodoItem { + return { + id: row.id, + title: row.title, + snippet: row.snippet, + status: row.status as TodoStatus, + profile: row.profile, + urgency: row.urgency, + starred: row.starred === 1, + snoozedUntil: row.snoozed_until, + contextHints: parseContextHints(row.context_hints), + clusterId: row.cluster_id, + goalId: row.goal_id, + sources, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToSource(row: TodoSourceRow): TodoSource { + return { + id: row.id, + itemId: row.item_id, + sourceType: row.source_type, + sourceId: row.source_id, + url: row.url, + title: row.title, + author: row.author, + timestamp: row.timestamp, + snippet: row.snippet ?? '', + }; +} + +// --- Scoring --- + +function computeUrgency(signal: WorkSignal): number { + const base = signal.urgencyHint ?? 0.3; + const ageMs = Date.now() - new Date(signal.timestamp).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const ageBoost = ageDays > 7 ? 0.1 : 0; + return Math.min(1.0, base + ageBoost); +} + +// --- Store --- + +export class WorkloadStore { + private db: Database.Database | null; + + constructor(db: Database.Database) { + this.db = db; + db.exec(SCHEMA); + log.info('WorkloadStore initialized (shared DB)'); + } + + close(): void { + // Don't close — DB is shared with TaskStore + this.db = null; + } + + private getDb(): Database.Database { + if (!this.db) throw new Error('WorkloadStore is closed'); + return this.db; + } + + /** + * Ingest a WorkSignal. Deduplicates by (sourceType, sourceId). + * - New source: creates a new item + source. + * - Existing source: updates timestamp, returns existing item. + */ + ingest(signal: WorkSignal): { item: TodoItem; created: boolean } { + const db = this.getDb(); + const now = Date.now(); + const signalTs = new Date(signal.timestamp).getTime(); + + // Validate timestamp format + if (isNaN(signalTs)) { + throw new Error(`Invalid timestamp format: ${signal.timestamp}`); + } + + // Check if this exact source already exists + const existingSource = db + .prepare('SELECT * FROM todo_sources WHERE source_type = ? AND source_id = ?') + .get(signal.sourceType, signal.sourceId) as TodoSourceRow | undefined; + + if (existingSource) { + // Source exists — update timestamp, return existing item + db.prepare('UPDATE todo_sources SET timestamp = ? WHERE id = ?').run( + signalTs, + existingSource.id, + ); + db.prepare('UPDATE todo_items SET updated_at = ? WHERE id = ?').run( + now, + existingSource.item_id, + ); + return { item: this.get(existingSource.item_id)!, created: false }; + } + + // New source — create a new item + const profile = signal.profile ?? 'default'; + const itemId = randomUUID(); + const hints = signal.contextHints + ? mergeContextHints({ ...EMPTY_HINTS }, signal.contextHints) + : { ...EMPTY_HINTS }; + + db.prepare( + `INSERT INTO todo_items (id, title, snippet, status, profile, urgency, starred, context_hints, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?, 0, ?, ?, ?)`, + ).run( + itemId, + signal.title, + signal.snippet || null, + profile, + computeUrgency(signal), + JSON.stringify(hints), + now, + now, + ); + + // Create source + const sourceId = randomUUID(); + db.prepare( + `INSERT INTO todo_sources (id, item_id, source_type, source_id, url, title, author, timestamp, snippet) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + sourceId, + itemId, + signal.sourceType, + signal.sourceId, + signal.url, + signal.title, + signal.author, + signalTs, + signal.snippet || null, + ); + + log.info('Ingested new work signal', { + itemId, + sourceType: signal.sourceType, + sourceId: signal.sourceId, + profile, + }); + + return { item: this.get(itemId)!, created: true }; + } + + /** + * Ingest multiple signals in a single transaction. + */ + ingestBatch(signals: WorkSignal[]): { items: TodoItem[]; created: number } { + const db = this.getDb(); + const results: TodoItem[] = []; + let created = 0; + + const tx = db.transaction(() => { + for (const signal of signals) { + const result = this.ingest(signal); + results.push(result.item); + if (result.created) created++; + } + }); + + tx(); + return { items: results, created }; + } + + get(id: string): TodoItem | null { + const row = this.getDb().prepare('SELECT * FROM todo_items WHERE id = ?').get(id) as + | TodoItemRow + | undefined; + if (!row) return null; + + const sourceRows = this.getDb() + .prepare('SELECT * FROM todo_sources WHERE item_id = ? ORDER BY timestamp DESC') + .all(id) as TodoSourceRow[]; + + return rowToItem(row, sourceRows.map(rowToSource)); + } + + list(options?: { profile?: string; status?: TodoStatus; starred?: boolean }): TodoItem[] { + const db = this.getDb(); + const conditions: string[] = []; + const values: unknown[] = []; + + if (options?.profile) { + conditions.push('profile = ?'); + values.push(options.profile); + } + if (options?.status) { + conditions.push('status = ?'); + values.push(options.status); + } + if (options?.starred !== undefined) { + conditions.push('starred = ?'); + values.push(options.starred ? 1 : 0); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const rows = db + .prepare( + `SELECT * FROM todo_items ${where} ORDER BY starred DESC, urgency DESC, created_at ASC`, + ) + .all(...values) as TodoItemRow[]; + + // Batch-load sources for all items + const itemIds = rows.map((r) => r.id); + const sourceMap = this.loadSourcesForItems(itemIds); + + return rows.map((row) => rowToItem(row, sourceMap.get(row.id) ?? [])); + } + + update(id: string, fields: TodoItemUpdateInput): TodoItem | null { + const existing = this.get(id); + if (!existing) return null; + + const sets: string[] = []; + const values: unknown[] = []; + + if (fields.title !== undefined) { + sets.push('title = ?'); + values.push(fields.title); + } + if (fields.status !== undefined) { + sets.push('status = ?'); + values.push(fields.status); + } + if (fields.starred !== undefined) { + sets.push('starred = ?'); + values.push(fields.starred ? 1 : 0); + } + if (fields.snoozedUntil !== undefined) { + sets.push('snoozed_until = ?'); + values.push(fields.snoozedUntil); + if (fields.snoozedUntil && fields.status === undefined) { + sets.push('status = ?'); + values.push('snoozed'); + } + } + if (fields.urgency !== undefined) { + sets.push('urgency = ?'); + values.push(fields.urgency); + } + if (fields.contextHints !== undefined) { + const merged = mergeContextHints(existing.contextHints, fields.contextHints); + sets.push('context_hints = ?'); + values.push(JSON.stringify(merged)); + } + + if (sets.length === 0) return existing; + + sets.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.getDb() + .prepare(`UPDATE todo_items SET ${sets.join(', ')} WHERE id = ?`) + .run(...values); + + return this.get(id); + } + + delete(id: string): boolean { + const result = this.getDb().prepare('DELETE FROM todo_items WHERE id = ?').run(id); + return result.changes > 0; + } + + /** + * Link a todo item to a task board goal (root task). + */ + setGoalId(itemId: string, goalId: string): TodoItem | null { + const db = this.getDb(); + db.prepare('UPDATE todo_items SET goal_id = ?, status = ?, updated_at = ? WHERE id = ?').run( + goalId, + 'acknowledged', + Date.now(), + itemId, + ); + return this.get(itemId); + } + + /** + * Complete items linked to a completed goal. + */ + completeByGoal(goalId: string): void { + this.getDb() + .prepare( + "UPDATE todo_items SET status = 'completed', updated_at = ? WHERE goal_id = ? AND status != 'completed'", + ) + .run(Date.now(), goalId); + } + + /** + * Unsnooze items whose snooze period has expired. + */ + unsnoozeDue(): number { + const today = new Date().toISOString().slice(0, 10); + const result = this.getDb() + .prepare( + "UPDATE todo_items SET status = 'active', snoozed_until = NULL, updated_at = ? WHERE status = 'snoozed' AND snoozed_until <= ?", + ) + .run(Date.now(), today); + return result.changes; + } + + /** + * Get profiles with item counts. + */ + profiles(): { profile: string; count: number }[] { + return this.getDb() + .prepare( + "SELECT profile, COUNT(*) as count FROM todo_items WHERE status != 'completed' GROUP BY profile ORDER BY count DESC", + ) + .all() as { profile: string; count: number }[]; + } + + private loadSourcesForItems(itemIds: string[]): Map { + if (itemIds.length === 0) return new Map(); + + const db = this.getDb(); + const placeholders = itemIds.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT * FROM todo_sources WHERE item_id IN (${placeholders}) ORDER BY timestamp DESC`, + ) + .all(...itemIds) as TodoSourceRow[]; + + const map = new Map(); + for (const row of rows) { + const sources = map.get(row.item_id) ?? []; + sources.push(rowToSource(row)); + map.set(row.item_id, sources); + } + return map; + } +} From 5f0333b705a874ef5a9b5166590ec151d84e78cf Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Tue, 28 Apr 2026 23:28:32 +0100 Subject: [PATCH 05/45] fix(client): add runtime validation for reconnected sessions field (#300) - Validate sessions is an array with proper shape before accessing - Guard against undefined/missing running field with explicit === false check - Add comprehensive tests for invalid shape handling Addresses Centaur review feedback on PR #295. Co-authored-by: Claude Opus 4.6 --- .../client/__tests__/protocol-parser.test.ts | 30 +++++++++++++++++++ packages/client/src/protocol-parser.ts | 21 ++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 63b93fe3..03ef7f30 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -654,6 +654,36 @@ describe('reconnected', () => { const r = parseServerMessage({ type: 'reconnected' }, state, makeCallbacks(), POOL_KEY); expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); }); + + it('no-ops when sessions has invalid shape (runtime validation)', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + // Invalid: sessions is not an array + const r1 = parseServerMessage( + { type: 'reconnected', sessions: { invalid: 'shape' } }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r1.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + + // Invalid: array contains entries missing required fields + const r2 = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1' }] }, // missing running field + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r2.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + + // Invalid: running field has wrong type + const r3 = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', running: 'yes' }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r3.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); }); // ─── error handling (session expired) ──────────────────────────────────────── diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index d0b87c04..2b65dc31 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -115,11 +115,24 @@ export function parseServerMessage( case 'reconnected': { result.connectionUpdate = { status: 'connected' }; - // Apply authoritative running state from the server for the active session - const sessions = msg.sessions as Array<{ sessionId: string; running: boolean }> | undefined; - if (sessions && state.currentSessionId) { + // Apply authoritative running state from the server for the active session. + // Validate runtime shape: sessions must be an array, and each entry must have + // sessionId (string) and running (boolean). Explicit running === false check + // guards against undefined/missing field. + const sessions = msg.sessions as unknown; + if ( + Array.isArray(sessions) && + state.currentSessionId && + sessions.every( + (s): s is { sessionId: string; running: boolean } => + typeof s === 'object' && + s !== null && + typeof s.sessionId === 'string' && + typeof s.running === 'boolean', + ) + ) { const active = sessions.find((s) => s.sessionId === state.currentSessionId); - if (active && !active.running) { + if (active && active.running === false) { result.messagesActions.push({ type: 'SET_RUNNING', running: false }); } } From 46121d5ff5e6f397279a21e4a090f2c6a52e6b2c Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 30 Apr 2026 00:39:35 +0100 Subject: [PATCH 06/45] fix(apns): respect APNS_PRODUCTION env var for sandbox/production gateway (#301) The APNs provider was hardcoded to production: true, causing token mismatches when iOS entitlements use the development environment. Now reads APNS_PRODUCTION from env (defaults to true, sandbox when "false"). Co-authored-by: Claude Opus 4.6 --- server/apns.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/apns.ts b/server/apns.ts index 4eae8ff4..d75fd167 100644 --- a/server/apns.ts +++ b/server/apns.ts @@ -12,6 +12,7 @@ const APNS_KEY_PATH = process.env.APNS_KEY_PATH; const APNS_KEY_ID = process.env.APNS_KEY_ID; const APNS_TEAM_ID = process.env.APNS_TEAM_ID; const APNS_BUNDLE_ID = process.env.APNS_BUNDLE_ID || 'com.mitzo.app'; +const APNS_PRODUCTION = process.env.APNS_PRODUCTION !== 'false'; let tokens: string[] = []; let tokenStorePath: string | null = null; @@ -76,8 +77,9 @@ function getProvider(): import('@parse/node-apn').Provider | null { keyId: APNS_KEY_ID!, teamId: APNS_TEAM_ID!, }, - production: true, + production: APNS_PRODUCTION, }); + log.info('APNs provider initialized', { production: APNS_PRODUCTION }); return apnProvider; } catch (err: unknown) { log.error('failed to initialize APNs provider', { From d1845f06e3f1fb0273c548774ccd24505ae23f43 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 30 Apr 2026 00:41:58 +0100 Subject: [PATCH 07/45] fix(ui): rename Todos to Telos, add back button, preserve scroll/filter (#302) * fix(ui): rename Todos to Telos, add back button, preserve scroll/filter - Rename "Todos" to "Telos" in TabBar tab label and TodoView header - Add back button to TodoDetailView via new PageHeader onBack prop - Preserve active profile filter and scroll position when navigating to detail view and back (passed via location state) - Update all affected tests (27 pass) Co-Authored-By: Claude Opus 4.6 * feat(ui): show sub-tasks in Telos detail view Render children as a tappable sub-task list with progress counter (completed/total) in the task detail view. Tapping a child navigates to its own detail view, preserving activeProfile and scrollTop state. Co-Authored-By: Claude Opus 4.6 * style(ui): fix Prettier formatting in TodoDetailView Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/components/PageHeader.tsx | 11 ++- frontend/src/components/TabBar.tsx | 2 +- .../src/components/__tests__/TabBar.test.tsx | 6 +- frontend/src/pages/TodoDetailView.tsx | 44 ++++++++- frontend/src/pages/TodoView.tsx | 29 ++++-- .../pages/__tests__/TodoDetailView.test.tsx | 93 +++++++++++++++++++ .../src/pages/__tests__/TodoView.test.tsx | 4 +- frontend/src/styles/global.css | 42 +++++++++ 8 files changed, 217 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 8ce1e29d..3f928e63 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -6,12 +6,19 @@ interface PageHeaderProps { badge?: number; center?: ReactNode; children?: ReactNode; + onBack?: () => void; } -export function PageHeader({ title, badge, center, children }: PageHeaderProps) { +export function PageHeader({ title, badge, center, children, onBack }: PageHeaderProps) { return (
- + {onBack ? ( + + ) : ( + + )} {center ? (
{center}
) : ( diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index dbfbdd10..ae050cbd 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -30,7 +30,7 @@ export function TabBar() { { label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') }, { label: 'Inbox', path: '/inbox', match: (p) => p === '/inbox', badge: inboxCount }, { - label: 'Todos', + label: 'Telos', path: '/todos', match: (p) => p === '/todos' || p.startsWith('/todos/'), badge: todoCount, diff --git a/frontend/src/components/__tests__/TabBar.test.tsx b/frontend/src/components/__tests__/TabBar.test.tsx index f1a7e5ac..b1b492b2 100644 --- a/frontend/src/components/__tests__/TabBar.test.tsx +++ b/frontend/src/components/__tests__/TabBar.test.tsx @@ -60,7 +60,7 @@ describe('TabBar', () => { expect(screen.getByText('Chat')).toBeTruthy(); expect(screen.getByText('Calendar')).toBeTruthy(); expect(screen.getByText('Inbox')).toBeTruthy(); - expect(screen.getByText('Todos')).toBeTruthy(); + expect(screen.getByText('Telos')).toBeTruthy(); expect(screen.getByText('More')).toBeTruthy(); }); @@ -82,9 +82,9 @@ describe('TabBar', () => { expect(badge.className).toContain('tab-bar-badge'); }); - it('does not show badge on Todos tab when count is 0', () => { + it('does not show badge on Telos tab when count is 0', () => { renderAt('/'); - const todosTab = screen.getByText('Todos').closest('button'); + const todosTab = screen.getByText('Telos').closest('button'); expect(todosTab?.querySelector('.tab-bar-badge')).toBeNull(); }); diff --git a/frontend/src/pages/TodoDetailView.tsx b/frontend/src/pages/TodoDetailView.tsx index 81bb6cfc..ce46818f 100644 --- a/frontend/src/pages/TodoDetailView.tsx +++ b/frontend/src/pages/TodoDetailView.tsx @@ -87,6 +87,13 @@ export function TodoDetailView() { navigate(`/chat?${params.toString()}`); } + function handleBack() { + const state = location.state as { activeProfile?: string; scrollTop?: number } | null; + navigate('/todos', { + state: { activeProfile: state?.activeProfile, scrollTop: state?.scrollTop }, + }); + } + function handleSourceClick(url: string) { window.open(url, '_blank', 'noopener,noreferrer'); } @@ -99,7 +106,7 @@ export function TodoDetailView() { return (
- + @@ -119,6 +126,41 @@ export function TodoDetailView() { {item.profile}
+ {item.children.length > 0 && ( +
+

+ Sub-tasks{' '} + + {item.completedChildCount}/{item.childCount} + +

+ {item.children.map((child) => ( +
{ + const state = location.state as { + activeProfile?: string; + scrollTop?: number; + } | null; + navigate(`/todos/${child.id}`, { + state: { + item: child, + activeProfile: state?.activeProfile, + scrollTop: state?.scrollTop, + }, + }); + }} + > + + {child.status === 'completed' ? '\u2713' : '\u25cb'} + + {child.summary} +
+ ))} +
+ )} + {item.sources.length > 0 && (

Sources

diff --git a/frontend/src/pages/TodoView.tsx b/frontend/src/pages/TodoView.tsx index 819c8d4b..badb0170 100644 --- a/frontend/src/pages/TodoView.tsx +++ b/frontend/src/pages/TodoView.tsx @@ -1,5 +1,5 @@ -import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useMitzoStore } from '@mitzo/client/hooks'; import { TodoCard } from '../components/TodoCard'; import { EmptyState } from '../components/EmptyState'; @@ -77,10 +77,25 @@ function TodoCreateForm({ export function TodoView() { const navigate = useNavigate(); - const [activeProfile, setActiveProfile] = useState(undefined); + const location = useLocation(); + const restoredProfile = (location.state as { activeProfile?: string } | null)?.activeProfile; + const [activeProfile, setActiveProfile] = useState(restoredProfile); const { loading, items, profiles, ack, done, star, create, refresh } = useTodoData(activeProfile); const [creating, setCreating] = useState<{ parentId?: string } | null>(null); const setPendingSession = useMitzoStore((s) => s.setPendingSession); + const scrollRef = useRef(null); + + // Restore scroll position when returning from detail view + useEffect(() => { + const saved = (location.state as { scrollTop?: number } | null)?.scrollTop; + if (saved && scrollRef.current) { + scrollRef.current.scrollTop = saved; + } + }, [location.state]); + + const saveScrollPosition = useCallback(() => { + return scrollRef.current?.scrollTop ?? 0; + }, []); function handleStartSession(item: TodoItem) { setPendingSession({ @@ -91,7 +106,9 @@ export function TodoView() { } function handleTap(item: TodoItem) { - navigate(`/todos/${item.id}`, { state: { item } }); + navigate(`/todos/${item.id}`, { + state: { item, activeProfile, scrollTop: saveScrollPosition() }, + }); } function handleAddChild(parentId: string) { @@ -100,7 +117,7 @@ export function TodoView() { return (
- + -
+
{profiles.length > 1 && (
+ ); +} + +// ─── Main component ───────────────────────────────────────────────────────── + +export function SessionOverview() { + const { activities, attendCount } = useSessionOverview(); + const navigate = useNavigate(); + + // Filter out idle/init — only show interesting sessions + const visible = activities.filter((a) => a.state !== 'idle' && a.state !== 'init'); + + // Auto-expand when tier 1 items exist + const hasUrgent = attendCount > 0; + const [manualOpen, setManualOpen] = useState(null); + const isOpen = manualOpen ?? hasUrgent; + + const toggleOpen = useCallback(() => { + setManualOpen((prev) => !(prev ?? hasUrgent)); + }, [hasUrgent]); + + const handleTap = useCallback( + (sessionId: string) => { + selectionChanged(); + navigate(`/chat/${sessionId}`); + }, + [navigate], + ); + + // Don't render at all if no interesting sessions + if (visible.length === 0) return null; + + // Build summary line + const waitingCount = visible.filter((a) => a.state === 'waiting').length; + const workingCount = visible.filter((a) => a.state === 'working').length; + const doneCount = visible.filter((a) => a.state === 'done').length; + const parts: string[] = []; + if (waitingCount > 0) parts.push(`${waitingCount} waiting`); + if (workingCount > 0) parts.push(`${workingCount} working`); + if (doneCount > 0) parts.push(`${doneCount} done`); + + return ( +
+ + {isOpen && ( +
+ {visible.map((a) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}min ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} diff --git a/frontend/src/components/__tests__/SessionOverview.test.tsx b/frontend/src/components/__tests__/SessionOverview.test.tsx new file mode 100644 index 00000000..38fb9027 --- /dev/null +++ b/frontend/src/components/__tests__/SessionOverview.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { SessionActivity } from '../../hooks/useSessionOverview'; + +// Mock navigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await import('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +// Mock haptics +vi.mock('../../lib/haptics', () => ({ + selectionChanged: vi.fn(), +})); + +// Mock the hook +const mockActivities: SessionActivity[] = []; +let mockAttendCount = 0; + +vi.mock('../../hooks/useSessionOverview', () => ({ + useSessionOverview: () => ({ + activities: mockActivities, + attendCount: mockAttendCount, + connected: true, + }), +})); + +import { SessionOverview } from '../SessionOverview'; + +afterEach(() => { + cleanup(); + mockNavigate.mockClear(); + mockActivities.length = 0; + mockAttendCount = 0; +}); + +function renderOverview() { + return render( + + + , + ); +} + +function makeActivity(overrides: Partial = {}): SessionActivity { + return { + sessionId: 'session-1', + clientId: 'client-1', + title: 'Test Session', + state: 'working', + flags: [], + lastEventAt: Date.now(), + ...overrides, + }; +} + +describe('SessionOverview', () => { + it('renders nothing when no interesting sessions', () => { + const { container } = renderOverview(); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when only idle/init sessions', () => { + mockActivities.push( + makeActivity({ state: 'idle' }), + makeActivity({ sessionId: 's2', state: 'init' }), + ); + const { container } = renderOverview(); + expect(container.innerHTML).toBe(''); + }); + + it('renders header with summary when working sessions exist', () => { + mockActivities.push(makeActivity({ state: 'working' })); + renderOverview(); + expect(screen.getByText('Active Sessions')).toBeTruthy(); + expect(screen.getByText('1 working')).toBeTruthy(); + }); + + it('shows badge when waiting sessions exist', () => { + mockActivities.push(makeActivity({ state: 'waiting', waitReason: 'permission' })); + mockAttendCount = 1; + renderOverview(); + expect(screen.getByText('1')).toBeTruthy(); + }); + + it('auto-expands when attend count > 0', () => { + mockActivities.push(makeActivity({ state: 'waiting' })); + mockAttendCount = 1; + renderOverview(); + // Cards should be visible + expect(screen.getByText('Test Session')).toBeTruthy(); + }); + + it('navigates on card tap', () => { + mockActivities.push(makeActivity({ state: 'working' })); + mockAttendCount = 1; // auto-expand + renderOverview(); + + const card = screen.getByText('Test Session').closest('button'); + fireEvent.click(card!); + expect(mockNavigate).toHaveBeenCalledWith('/chat/session-1'); + }); + + it('shows repo prefix in card title', () => { + mockActivities.push(makeActivity({ state: 'working', repo: 'mitzo' })); + mockAttendCount = 1; + renderOverview(); + expect(screen.getByText('mitzo:')).toBeTruthy(); + }); + + it('shows progress in card meta', () => { + mockActivities.push(makeActivity({ state: 'working', progress: { done: 3, total: 7 } })); + mockAttendCount = 1; + renderOverview(); + // The meta text should contain progress + const meta = document.querySelector('.overview-card-meta'); + expect(meta?.textContent).toContain('3/7'); + }); + + it('toggles expansion on header click', () => { + mockActivities.push(makeActivity({ state: 'working' })); + // No attend count, so starts collapsed + mockAttendCount = 0; + renderOverview(); + + // Should be collapsed initially (no card visible) + expect(screen.queryByText('Test Session')).toBeNull(); + + // Click header to expand + fireEvent.click(screen.getByText('Active Sessions')); + expect(screen.getByText('Test Session')).toBeTruthy(); + + // Click again to collapse + fireEvent.click(screen.getByText('Active Sessions')); + expect(screen.queryByText('Test Session')).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useSessionOverview.test.ts b/frontend/src/hooks/__tests__/useSessionOverview.test.ts new file mode 100644 index 00000000..80559d4f --- /dev/null +++ b/frontend/src/hooks/__tests__/useSessionOverview.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock the event bus singleton +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockOn: any = vi.fn(() => vi.fn()); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockOnConnectionChange: any = vi.fn(() => vi.fn()); +const mockConnected = false; + +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: (...args: unknown[]) => mockOn(...args), + onConnectionChange: (...args: unknown[]) => mockOnConnectionChange(...args), + get connected() { + return mockConnected; + }, + }, +})); + +import { useSessionOverview, type SessionActivity } from '../useSessionOverview'; + +beforeEach(() => { + mockOn.mockClear(); + mockOnConnectionChange.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeActivity(overrides: Partial = {}): SessionActivity { + return { + sessionId: 'session-1', + clientId: 'client-1', + title: 'Test Session', + state: 'working', + flags: [], + lastEventAt: Date.now(), + ...overrides, + }; +} + +describe('useSessionOverview', () => { + it('starts with empty activities', () => { + const { result } = renderHook(() => useSessionOverview()); + expect(result.current.activities).toEqual([]); + expect(result.current.attendCount).toBe(0); + }); + + it('subscribes to session_activity events on mount', () => { + renderHook(() => useSessionOverview()); + expect(mockOn).toHaveBeenCalledWith('session_activity', expect.any(Function)); + }); + + it('subscribes to connection changes', () => { + renderHook(() => useSessionOverview()); + expect(mockOnConnectionChange).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('unsubscribes on unmount', () => { + const unsubActivity = vi.fn(); + const unsubConnection = vi.fn(); + mockOn.mockReturnValue(unsubActivity); + mockOnConnectionChange.mockReturnValue(unsubConnection); + + const { unmount } = renderHook(() => useSessionOverview()); + unmount(); + + expect(unsubActivity).toHaveBeenCalled(); + expect(unsubConnection).toHaveBeenCalled(); + }); + + it('updates activities when session_activity event fires', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + const activities = [ + makeActivity({ sessionId: 's1', state: 'working' }), + makeActivity({ sessionId: 's2', state: 'waiting', waitReason: 'permission' }), + ]; + + act(() => { + activityHandler!(activities); + }); + + expect(result.current.activities).toHaveLength(2); + // Waiting (tier 1) should sort before working (tier 3) + expect(result.current.activities[0].state).toBe('waiting'); + expect(result.current.activities[1].state).toBe('working'); + }); + + it('computes attendCount from waiting sessions', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + act(() => { + activityHandler!([ + makeActivity({ sessionId: 's1', state: 'waiting' }), + makeActivity({ sessionId: 's2', state: 'waiting' }), + makeActivity({ sessionId: 's3', state: 'working' }), + ]); + }); + + expect(result.current.attendCount).toBe(2); + }); + + it('sorts by tier then by recency within tier', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + const now = Date.now(); + act(() => { + activityHandler!([ + makeActivity({ sessionId: 's1', state: 'done', lastEventAt: now - 1000 }), + makeActivity({ sessionId: 's2', state: 'working', lastEventAt: now }), + makeActivity({ sessionId: 's3', state: 'done', lastEventAt: now }), + makeActivity({ sessionId: 's4', state: 'waiting', lastEventAt: now }), + ]); + }); + + const ids = result.current.activities.map((a) => a.sessionId); + // Tier 1 (waiting) → Tier 2 (done, newest first) → Tier 3 (working) + expect(ids).toEqual(['s4', 's3', 's1', 's2']); + }); +}); diff --git a/frontend/src/hooks/useSessionOverview.ts b/frontend/src/hooks/useSessionOverview.ts new file mode 100644 index 00000000..75524998 --- /dev/null +++ b/frontend/src/hooks/useSessionOverview.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useMemo } from 'react'; +import { eventBus } from '../lib/event-bus-singleton'; + +// ─── Types (mirror server/session-overview.ts) ────────────────────────────── + +export type SessionActivityState = 'init' | 'working' | 'waiting' | 'done' | 'idle' | 'paused'; + +export type WaitReason = 'permission' | 'review' | 'blocked'; + +export interface SessionActivity { + sessionId: string; + clientId: string; + title: string; + repo?: string; + state: SessionActivityState; + flags: SessionActivityState[]; + waitReason?: WaitReason; + progress?: { done: number; total: number }; + lastEventAt: number; + taskId?: string; +} + +// ─── Tier sorting ─────────────────────────────────────────────────────────── + +type AttendTier = 1 | 2 | 3 | 4; + +function getTier(activity: SessionActivity): AttendTier { + if (activity.state === 'waiting') return 1; + if (activity.state === 'done') return 2; + if (activity.state === 'working') return 3; + return 4; // init, idle, paused +} + +function sortByTier(activities: SessionActivity[]): SessionActivity[] { + return [...activities].sort((a, b) => { + const tierDiff = getTier(a) - getTier(b); + if (tierDiff !== 0) return tierDiff; + // Within same tier, most recent first + return b.lastEventAt - a.lastEventAt; + }); +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export interface UseSessionOverviewReturn { + /** All active sessions, sorted by attention tier. */ + activities: SessionActivity[]; + /** Number of Tier 1 (needs you) sessions. */ + attendCount: number; + /** Whether SSE is connected. */ + connected: boolean; +} + +export function useSessionOverview(): UseSessionOverviewReturn { + const [activities, setActivities] = useState([]); + const [connected, setConnected] = useState(eventBus.connected); + + useEffect(() => { + const unsubActivity = eventBus.on('session_activity', (data) => { + setActivities(data as SessionActivity[]); + }); + const unsubConnection = eventBus.onConnectionChange((c) => setConnected(c)); + return () => { + unsubActivity(); + unsubConnection(); + }; + }, []); + + const sorted = useMemo(() => sortByTier(activities), [activities]); + const attendCount = useMemo( + () => activities.filter((a) => a.state === 'waiting').length, + [activities], + ); + + return { activities: sorted, attendCount, connected }; +} diff --git a/frontend/src/lib/event-bus-singleton.ts b/frontend/src/lib/event-bus-singleton.ts new file mode 100644 index 00000000..a3945129 --- /dev/null +++ b/frontend/src/lib/event-bus-singleton.ts @@ -0,0 +1,15 @@ +/** + * Global EventBus singleton for SSE events. + * + * Lazily connected on first import. Hooks subscribe via eventBus.on(). + * On iOS resume, ensureConnected() is called to recover from CLOSED state. + */ + +import { EventBus } from '@mitzo/client'; +import { getApiBaseUrl } from './api-fetch'; + +export const eventBus = new EventBus(); + +// Connect immediately — EventSource auto-reconnects natively +const sseUrl = `${getApiBaseUrl()}/api/events`; +eventBus.connect(sseUrl); diff --git a/frontend/src/pages/SessionList.tsx b/frontend/src/pages/SessionList.tsx index cf313bad..016161f4 100644 --- a/frontend/src/pages/SessionList.tsx +++ b/frontend/src/pages/SessionList.tsx @@ -13,6 +13,7 @@ import { formatTokens } from '../lib/formatTokens'; import { BriefingCard } from '../components/BriefingCard'; import { SessionSearchBar } from '../components/SessionSearchBar'; import { useSessionSearch } from '../hooks/useSessionSearch'; +import { SessionOverview } from '../components/SessionOverview'; function SwipeableSession({ session, @@ -308,6 +309,8 @@ export function SessionList() { + + {quickActions.length > 0 && (
+ + {expanded && ( +
+ {blocks.map((block) => { + if (block.blockType === 'thinking' || block.blockType === 'redacted_thinking') { + return ; + } + if (block.blockType === 'tool_use') { + return ; + } + if (block.blockType === 'text') { + return ( +
+ {block.content} +
+ ); + } + return null; + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ToolPill.tsx b/frontend/src/components/ToolPill.tsx index 84dde5a7..be517c7f 100644 --- a/frontend/src/components/ToolPill.tsx +++ b/frontend/src/components/ToolPill.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { StreamingBlock, FinishedBlock, RawToolInput } from '../types/chat'; +import { SubagentCard } from './SubagentCard'; interface Props { block: StreamingBlock | FinishedBlock; @@ -71,6 +72,7 @@ export function ToolPill({ block }: Props) { )}
)} + {block.subagent && }
); } diff --git a/frontend/src/components/__tests__/SubagentCard.test.tsx b/frontend/src/components/__tests__/SubagentCard.test.tsx new file mode 100644 index 00000000..90a99b09 --- /dev/null +++ b/frontend/src/components/__tests__/SubagentCard.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { SubagentCard } from '../SubagentCard'; +import type { FinishedBlock } from '../../types/chat'; + +afterEach(() => cleanup()); + +describe('SubagentCard', () => { + it('renders collapsed by default with summary', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: [ + { + blockId: 'b1', + blockType: 'text' as const, + content: 'Subagent output', + }, + ], + summary: 'Search complete', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }; + + render(); + + expect(screen.getByText('Search complete')).toBeTruthy(); + expect(screen.getByText(/100.*50/)).toBeTruthy(); // Token display + }); + + it('renders "Working..." when subagent is still running', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'thinking' as const, + content: 'Analyzing...', + done: false, + }, + ], + ]), + blockOrder: ['b1'], + running: true as const, + }; + + render(); + + expect(screen.getByText('Working...')).toBeTruthy(); + }); + + it('expands to show nested blocks when clicked', () => { + const blocks: FinishedBlock[] = [ + { + blockId: 'b1', + blockType: 'thinking', + content: 'Let me search for that', + }, + { + blockId: 'b2', + blockType: 'text', + content: 'Search results here', + }, + ]; + + const subagent = { + messageId: 'msg-sub-1', + blocks, + summary: 'Done', + }; + + const { container } = render(); + + // Initially collapsed - detail not visible + expect(container.querySelector('.subagent-detail')).not.toBeTruthy(); + + // Click to expand + const header = container.querySelector('.subagent-header'); + if (header) { + fireEvent.click(header); + } + + // Now detail section is visible + expect(container.querySelector('.subagent-detail')).toBeTruthy(); + expect(screen.getByText('Search results here')).toBeTruthy(); + }); + + it('shows pulsing indicator when running', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: new Map(), + blockOrder: [], + running: true as const, + }; + + const { container } = render(); + + const dot = container.querySelector('.subagent-dot--running'); + expect(dot).toBeTruthy(); + }); + + it('shows checkmark when complete', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: [], + summary: 'Complete', + }; + + const { container } = render(); + + const dot = container.querySelector('.subagent-dot--done'); + expect(dot).toBeTruthy(); + }); +}); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f06de3c0..1c5150b0 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -5237,3 +5237,87 @@ textarea:focus { .isolation-toggle:hover { opacity: 0.8; } + +/* ━━━ Subagent Card ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.subagent-card { + margin: 8px 0 0 0; + padding-left: 12px; + border-left: 2px solid var(--success); + background: rgba(var(--success-rgb), 0.05); + border-radius: 4px; + overflow: hidden; +} + +.subagent-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 8px 0; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + font-family: inherit; + font-size: var(--text-sm); + color: var(--text); +} + +.subagent-header:active { + background: rgba(255, 255, 255, 0.03); +} + +.subagent-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.subagent-dot--running { + background: var(--success); + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.subagent-dot--done { + background: var(--success); +} + +.subagent-summary { + flex: 1; + text-align: left; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subagent-tokens { + font-size: var(--text-xs); + color: var(--text-dim); + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex-shrink: 0; +} + +.subagent-chevron { + color: var(--text-dim); + font-size: var(--text-xs); + flex-shrink: 0; +} + +.subagent-detail { + padding: 0 12px 8px 0; +} + +.subagent-text { + padding: 8px 0; + color: var(--text-dim); + font-size: var(--text-sm); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} From 473e1d5c5f5d4c300e22a1561445fe66f81a8e3a Mon Sep 17 00:00:00 2001 From: dimakis Date: Sun, 3 May 2026 23:31:44 +0100 Subject: [PATCH 20/45] fix(subagent): correct blockType in subagent_block_end, preserve subagent state in finishCurrent, add --success-rgb CSS var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three code review fixes for subagent visibility: 1. subagentBlockIdByIndex now stores {blockId, blockType} instead of just blockId, so content_block_stop can emit the correct blockType (was always evaluating to 'text' because the map lookup is truthy). 2. finishCurrent() now copies the subagent field from StreamingBlock to FinishedBlock, preventing subagent UI state from being dropped on message finalization. 3. Added --success-rgb: 76, 175, 80 to :root in global.css — used by SubagentCard's background but never defined. Co-Authored-By: Claude Opus 4.6 --- frontend/src/styles/global.css | 1 + .../client/__tests__/messages-slice.test.ts | 71 +++++++++++- packages/client/src/slices/messages.ts | 1 + server/__tests__/query-loop.test.ts | 103 ++++++++++++++++++ server/query-loop.ts | 14 ++- 5 files changed, 182 insertions(+), 8 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 1c5150b0..0a0fd92d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -14,6 +14,7 @@ --accent-hover: #5a52e0; --danger: #f44336; --success: #4caf50; + --success-rgb: 76, 175, 80; --code-bg: #0e0e10; --code-font: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace; diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index 2527de5c..a80e25af 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { messagesReducer, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; +import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; import type { MessagesState } from '../src/slices/messages.js'; -import type { FinishedBlock } from '@mitzo/protocol'; +import type { FinishedBlock, StreamingMessage } from '@mitzo/protocol'; const INITIAL = INITIAL_MESSAGES_STATE; @@ -1140,6 +1140,73 @@ describe('CONNECTION_LOST', () => { }); }); +// ─── finishCurrent (subagent preservation) ────────────────────────────────── + +describe('finishCurrent', () => { + it('preserves the subagent field when converting StreamingBlock to FinishedBlock', () => { + const subagentState = { + messageId: 'sub-msg-1', + blocks: new Map([ + [ + 'sub-b1', + { + blockId: 'sub-b1', + blockType: 'text' as const, + content: 'subagent output', + done: true, + }, + ], + ]), + blockOrder: ['sub-b1'], + running: true as const, + }; + + const current: StreamingMessage = { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Agent', + subagent: subagentState, + }, + ], + ]), + blockOrder: ['b1'], + }; + + const finished = finishCurrent(current); + expect(finished.blocks[0].subagent).toBeDefined(); + expect(finished.blocks[0].subagent!.messageId).toBe('sub-msg-1'); + }); + + it('works normally when subagent field is absent', () => { + const current: StreamingMessage = { + messageId: 'msg-2', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'text' as const, + content: 'hello', + done: true, + }, + ], + ]), + blockOrder: ['b1'], + }; + + const finished = finishCurrent(current); + expect(finished.blocks[0].subagent).toBeUndefined(); + expect(finished.blocks[0].content).toBe('hello'); + }); +}); + // ─── NATIVE_COMMAND_RESULT ────────────────────────────────────────────────── describe('NATIVE_COMMAND_RESULT', () => { diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index c3a80591..5bb378bf 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -152,6 +152,7 @@ export function finishCurrent(current: StreamingMessage): FinishedMessage { rawInput: b.rawInput, toolResult: b.toolResult, toolError: b.toolError, + subagent: b.subagent, }; }); return { messageId: current.messageId, role: 'assistant', blocks, timestamp: Date.now() }; diff --git a/server/__tests__/query-loop.test.ts b/server/__tests__/query-loop.test.ts index 79c0cb19..c0e64a15 100644 --- a/server/__tests__/query-loop.test.ts +++ b/server/__tests__/query-loop.test.ts @@ -2016,4 +2016,107 @@ describe('runQueryLoop', () => { expect(resultEvent!.attributes!['tool.is_error']).toBe(false); }); }); + + describe('subagent block type tracking', () => { + it('emits correct blockType for thinking blocks in subagent_block_end', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-sa1' } } }, + // Parent tool_use block (Agent tool) + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', name: 'Agent', id: 'agent-tool-1' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"prompt":"test"}' }, + }, + }, + // Subagent message_start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'message_start', message: { id: 'sub-msg-1' } }, + }, + // Subagent thinking block start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking' }, + }, + }, + // Subagent thinking delta + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }, + }, + // Subagent thinking block stop — should produce blockType: 'thinking', not 'text' + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'content_block_stop', index: 0 }, + }, + // Subagent text block start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_start', + index: 1, + content_block: { type: 'text' }, + }, + }, + // Subagent text delta + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Response' }, + }, + }, + // Subagent text block stop — should produce blockType: 'text' + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'content_block_stop', index: 1 }, + }, + // End parent tool_use block + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-sa' }, + { type: 'result', session_id: 'sess-sa' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const sent = transport.sent; + + // Find the subagent_block_end events + const subagentBlockEnds = sent.filter((m) => m.type === 'subagent_block_end'); + expect(subagentBlockEnds.length).toBeGreaterThanOrEqual(2); + + // First subagent_block_end should be thinking (index 0 was a thinking block) + const thinkingEnd = subagentBlockEnds.find((m) => m.blockType === 'thinking'); + expect(thinkingEnd).toBeDefined(); + + // Second subagent_block_end should be text (index 1 was a text block) + const textEnd = subagentBlockEnds.find((m) => m.blockType === 'text'); + expect(textEnd).toBeDefined(); + }); + }); }); diff --git a/server/query-loop.ts b/server/query-loop.ts index ecf7ea1e..5e3d74fd 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -187,7 +187,7 @@ async function _runQueryLoopInner( parentBlockId: string; parentToolName: string; subagentMessageId: string | null; - subagentBlockIdByIndex: Map; + subagentBlockIdByIndex: Map; subagentToolInputBuffers: Map; usage: { inputTokens: number; @@ -697,8 +697,8 @@ async function _runQueryLoopInner( const contentBlock = evt.content_block as Record | undefined; const index = evt.index as number; const blockId = nextBlockId(); - subagent.subagentBlockIdByIndex.set(index, blockId); const blockType = contentBlock?.type as string | undefined; + subagent.subagentBlockIdByIndex.set(index, { blockId, blockType: blockType ?? 'text' }); if (blockType === 'thinking' || blockType === 'redacted_thinking') { emit( @@ -828,7 +828,8 @@ async function _runQueryLoopInner( if (subagent) { const delta = evt.delta as Record | undefined; const index = evt.index as number; - const blockId = subagent.subagentBlockIdByIndex.get(index); + const blockEntry = subagent.subagentBlockIdByIndex.get(index); + const blockId = blockEntry?.blockId; if (delta?.type === 'text_delta' && blockId) { emit( @@ -902,7 +903,8 @@ async function _runQueryLoopInner( // Subagent content_block_stop if (subagent) { const index = evt.index as number; - const blockId = subagent.subagentBlockIdByIndex.get(index); + const blockEntry = subagent.subagentBlockIdByIndex.get(index); + const blockId = blockEntry?.blockId; const toolEntry = subagent.subagentToolInputBuffers.get(index); if (toolEntry && blockId) { @@ -935,13 +937,13 @@ async function _runQueryLoopInner( toolId: toolEntry.id, }); } else if (blockId) { - // Text or thinking block + // Text or thinking block — use the stored blockType emit( v2('subagent_block_end', { parentBlockId: subagent.parentBlockId, subagentMessageId: subagent.subagentMessageId, blockId, - blockType: subagent.subagentBlockIdByIndex.get(index) ? 'text' : 'thinking', + blockType: blockEntry!.blockType as 'text' | 'thinking', }), ); } From 90e0e0e271f59ef2aa0249e75ed300b6867fba5e Mon Sep 17 00:00:00 2001 From: dimakis Date: Sun, 3 May 2026 23:00:16 +0100 Subject: [PATCH 21/45] fix(connection-registry): add delivery guarantee with cursors and periodic sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Events could be lost when broadcast() failed during: - iOS backgrounding killing WebSocket mid-stream - Network timing races (WS closes before send completes) - Connection drops during event delivery The bug manifested as stuck "Running..." UI — subagents completed but clients never received the session_end event. ## Root Cause Event delivery was fire-and-forget. broadcast() sent events via WS but had no retry logic. If send() failed, the event was lost. EventStore replay only worked if the client reconnected — events lost between disconnect and reconnect were gone forever. ## Solution Implement connection-level event cursors with periodic sync: 1. **Cursor tracking**: Each connection maintains last-delivered seq per session. Updated on successful broadcast(). 2. **Periodic sync**: Background task (5s interval) retries missed events by comparing cursor vs EventStore. Bounded to 50 events/round to avoid overwhelming slow connections. 3. **Reconnect reset**: Cursor resets to client's lastSeq on reconnect to prevent duplicate replay (EventStore handles the gap). 4. **Out-of-order safety**: Cursor only advances forward, handles reordered delivery gracefully. ## Changes - `ConnectionRegistry`: Add cursors map, periodic sync, resetCursor() - `broadcast()`: Update cursor on successful send - `handleReconnect()`: Reset cursor after EventStore replay - `server/index.ts`: Wire EventStore, start/stop periodic sync - Tests: 14 new tests for cursors, sync, edge cases ## Edge Cases Handled - ✅ Broadcast fails → cursor stays behind, sync retries - ✅ EventStore append + broadcast race → sync catches within 5s - ✅ Slow clients → sync rate-limited (50 events/round) - ✅ Multi-device → independent cursors per connection - ✅ Observer connections → same cursor mechanism - ✅ Session ends while suspended → sync delivers on resume - ✅ Reconnect with stale cursor → reset prevents dupes Fixes the stalled Agent tool bug from PR #239 follow-up. Co-Authored-By: Claude Sonnet 4.5 --- .../__tests__/connection-registry.test.ts | 274 +++++++++++++++++- packages/harness/src/connection-registry.ts | 164 ++++++++++- packages/harness/src/index.ts | 2 +- server/index.ts | 8 + server/ws-handler-v2.ts | 5 + 5 files changed, 448 insertions(+), 5 deletions(-) diff --git a/packages/harness/__tests__/connection-registry.test.ts b/packages/harness/__tests__/connection-registry.test.ts index 045ee671..1984ab1c 100644 --- a/packages/harness/__tests__/connection-registry.test.ts +++ b/packages/harness/__tests__/connection-registry.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ConnectionRegistry } from '../src/connection-registry.js'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { ConnectionRegistry, type EventStoreAdapter } from '../src/connection-registry.js'; import type { SessionTransport } from '../src/session-transport.js'; function mockTransport(open = true): SessionTransport { @@ -9,6 +9,17 @@ function mockTransport(open = true): SessionTransport { }; } +function mockEventStore( + events: Array<{ seq: number; payload: Record }> = [], +): EventStoreAdapter { + return { + getEventsAfter: vi.fn((sessionId: string, afterSeq: number, limit?: number) => { + const filtered = events.filter((e) => e.seq > afterSeq); + return limit ? filtered.slice(0, limit) : filtered; + }), + }; +} + describe('ConnectionRegistry', () => { let registry: ConnectionRegistry; @@ -201,4 +212,263 @@ describe('ConnectionRegistry', () => { expect(() => registry.broadcastAll({ type: 'test' })).not.toThrow(); }); }); + + describe('cursor tracking', () => { + it('initializes cursor map when registering a connection', () => { + registry.register('conn-1', mockTransport()); + // Cursor map is private, but we can verify behavior via broadcast + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 10 }); + // No error means cursor map exists + }); + + it('updates cursor on successful broadcast', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'msg1', seq: 5 }); + registry.broadcast('sess-a', { type: 'msg2', seq: 10 }); + + // Cursor should be at 10 now (can't inspect directly, but periodic sync will use it) + expect(t.send).toHaveBeenCalledTimes(2); + }); + + it('does not update cursor when send fails', () => { + const t = mockTransport(true); + (t.send as ReturnType).mockImplementationOnce(() => { + throw new Error('socket closing'); + }); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'failed', seq: 5 }); + // Cursor should stay at 0 (initial state) + + // Now send succeeds + (t.send as ReturnType).mockImplementation(() => {}); + registry.broadcast('sess-a', { type: 'success', seq: 10 }); + // Cursor should jump to 10 + }); + + it('handles out-of-order delivery by only advancing cursor forward', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'msg1', seq: 10 }); + registry.broadcast('sess-a', { type: 'msg2', seq: 5 }); // Out of order + registry.broadcast('sess-a', { type: 'msg3', seq: 15 }); + + // Cursor should be at 15 (max seen), not 5 + expect(t.send).toHaveBeenCalledTimes(3); + }); + + it('cleans up cursors when connection is removed', () => { + registry.register('conn-1', mockTransport()); + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 10 }); + + registry.remove('conn-1'); + // No error on subsequent broadcast means cursor cleanup succeeded + registry.register('conn-1', mockTransport()); + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 20 }); + }); + }); + + describe('resetCursor', () => { + it('resets cursor to client lastSeq on reconnect', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + // Simulate cursor drift (broadcast moved cursor to 100) + registry.broadcast('sess-a', { type: 'msg', seq: 100 }); + + // Client reconnects with lastSeq=50 (missed 51-100) + registry.resetCursor('conn-1', 'sess-a', 50); + + // Cursor should now be at 50 (verified by periodic sync behavior) + }); + + it('is a no-op for unknown connection', () => { + expect(() => registry.resetCursor('unknown', 'sess-a', 10)).not.toThrow(); + }); + }); + + describe('periodic sync', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries missed events from EventStore', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1', data: 'a' } }, + { seq: 10, payload: { type: 'msg2', data: 'b' } }, + { seq: 15, payload: { type: 'msg3', data: 'c' } }, + ]); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + // Simulate cursor at 0 (never delivered anything) + registry.startPeriodicSync(); + + // Advance time to trigger sync + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch events > 0 from store and deliver them + expect(store.getEventsAfter).toHaveBeenCalledWith('sess-a', 0, 50); + expect(t.send).toHaveBeenCalledTimes(3); + expect(t.send).toHaveBeenCalledWith({ type: 'msg1', data: 'a', seq: 5 }); + expect(t.send).toHaveBeenCalledWith({ type: 'msg2', data: 'b', seq: 10 }); + expect(t.send).toHaveBeenCalledWith({ type: 'msg3', data: 'c', seq: 15 }); + + registry.stopPeriodicSync(); + }); + + it('stops retrying on first send failure in a batch', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + let callCount = 0; + (t.send as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error('socket dead'); + }); + + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1' } }, + { seq: 10, payload: { type: 'msg2' } }, + { seq: 15, payload: { type: 'msg3' } }, + ]); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should send msg1 (success), msg2 (fail), then stop + expect(t.send).toHaveBeenCalledTimes(2); + + registry.stopPeriodicSync(); + }); + + it('skips connections with closed transports', async () => { + vi.useFakeTimers(); + const tClosed = mockTransport(false); + const store = mockEventStore([{ seq: 5, payload: { type: 'test' } }]); + + registry.setEventStore(store); + registry.register('conn-closed', tClosed); + registry.watch('conn-closed', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(tClosed.send).not.toHaveBeenCalled(); + + registry.stopPeriodicSync(); + }); + + it('respects SYNC_BATCH_LIMIT to avoid overwhelming slow clients', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const manyEvents = Array.from({ length: 100 }, (_, i) => ({ + seq: i + 1, + payload: { type: 'msg', i }, + })); + const store = mockEventStore(manyEvents); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch with limit=50 + expect(store.getEventsAfter).toHaveBeenCalledWith('sess-a', 0, 50); + expect(t.send).toHaveBeenCalledTimes(50); + + registry.stopPeriodicSync(); + }); + + it('handles EventStore fetch errors gracefully', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store: EventStoreAdapter = { + getEventsAfter: vi.fn(() => { + throw new Error('database locked'); + }), + }; + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + // Sync should not throw even when EventStore fetch fails + await expect(vi.advanceTimersByTimeAsync(5000)).resolves.not.toThrow(); + + expect(t.send).not.toHaveBeenCalled(); + + registry.stopPeriodicSync(); + }); + + it('is a no-op when EventStore not set', () => { + const registry2 = new ConnectionRegistry(); + expect(() => registry2.startPeriodicSync()).not.toThrow(); + // No timer started, so no cleanup needed + }); + + it('warns when starting sync twice', () => { + const registry2 = new ConnectionRegistry(); + const store = mockEventStore(); + registry2.setEventStore(store); + registry2.startPeriodicSync(); + // Second call should warn but not crash + expect(() => registry2.startPeriodicSync()).not.toThrow(); + registry2.stopPeriodicSync(); + }); + + it('stops periodic sync and clears timer', () => { + vi.useFakeTimers(); + const store = mockEventStore(); + registry.setEventStore(store); + registry.startPeriodicSync(); + registry.stopPeriodicSync(); + + // Timer should be cleared — no sync fires + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + vi.advanceTimersByTime(5000); + expect(t.send).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('stops periodic sync and clears all state', () => { + vi.useFakeTimers(); + const store = mockEventStore(); + registry.setEventStore(store); + registry.register('conn-1', mockTransport()); + registry.startPeriodicSync(); + + registry.dispose(); + + expect(registry.get('conn-1')).toBeUndefined(); + + // Timer stopped — no sync fires + vi.advanceTimersByTime(5000); + expect(store.getEventsAfter).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/harness/src/connection-registry.ts b/packages/harness/src/connection-registry.ts index 87dea699..a84fb606 100644 --- a/packages/harness/src/connection-registry.ts +++ b/packages/harness/src/connection-registry.ts @@ -8,6 +8,12 @@ * * Part of the v2 single-WS protocol — replaces the per-session WsPool * model where each session had its own socket. + * + * Delivery Guarantee: + * - Tracks per-connection per-session cursors (last delivered seq) + * - broadcast() updates cursor on successful send + * - Periodic sync retries events beyond cursor (handles WS races, iOS kills) + * - Reconnect resets cursor to client's lastSeq to prevent duplicate replay */ import type { SessionTransport } from './session-transport.js'; @@ -22,8 +28,29 @@ export interface Connection { activeSession: string | null; } +/** Event store interface for periodic sync — injected to avoid circular deps */ +export interface EventStoreAdapter { + getEventsAfter( + sessionId: string, + afterSeq: number, + limit?: number, + ): Array<{ + seq: number; + payload: Record; + }>; +} + +// Periodic sync fires every 5s to retry missed events +const SYNC_INTERVAL_MS = 5000; +// Limit events per sync round per connection to avoid overwhelming slow clients +const SYNC_BATCH_LIMIT = 50; + export class ConnectionRegistry { private connections = new Map(); + // Per-connection per-session cursors: last successfully delivered seq + private cursors = new Map>(); + private syncTimer: ReturnType | null = null; + private eventStore: EventStoreAdapter | null = null; register(connectionId: string, transport: SessionTransport): void { this.connections.set(connectionId, { @@ -32,6 +59,8 @@ export class ConnectionRegistry { watchedSessions: new Set(), activeSession: null, }); + // Initialize cursor map for this connection + this.cursors.set(connectionId, new Map()); } get(connectionId: string): Connection | undefined { @@ -40,6 +69,16 @@ export class ConnectionRegistry { remove(connectionId: string): void { this.connections.delete(connectionId); + // Clean up cursors for this connection + this.cursors.delete(connectionId); + } + + /** + * Set the EventStore adapter for periodic sync. + * Must be called before starting periodic sync. + */ + setEventStore(eventStore: EventStoreAdapter): void { + this.eventStore = eventStore; } watch(connectionId: string, sessionId: string): void { @@ -97,14 +136,28 @@ export class ConnectionRegistry { /** * Send a message to all open connections watching a session. * Catches send errors to prevent one failing transport from - * aborting the broadcast loop. + * aborting the broadcast loop. Updates delivery cursor on success + * so periodic sync can retry failures. */ broadcast(sessionId: string, data: Record): void { + const seq = data.seq as number | undefined; for (const { connectionId, transport } of this.getConnectionsWatching(sessionId, true)) { try { transport.send(data); + // Update cursor on successful delivery (if event has seq) + if (seq !== undefined) { + const connCursors = this.cursors.get(connectionId); + if (connCursors) { + const current = connCursors.get(sessionId) ?? 0; + // Only advance cursor forward (handle out-of-order delivery) + if (seq > current) { + connCursors.set(sessionId, seq); + } + } + } } catch { - log.warn('broadcast send failed', { connectionId, sessionId }); + log.warn('broadcast send failed', { connectionId, sessionId, seq }); + // Cursor not updated → periodic sync will retry } } } @@ -125,4 +178,111 @@ export class ConnectionRegistry { } } } + + /** + * Reset the cursor for a connection+session pair to the client's lastSeq. + * Called on reconnect to sync cursor with client state and prevent + * duplicate replay (EventStore handles the gap). + */ + resetCursor(connectionId: string, sessionId: string, clientLastSeq: number): void { + const connCursors = this.cursors.get(connectionId); + if (!connCursors) return; + connCursors.set(sessionId, clientLastSeq); + log.info('cursor reset on reconnect', { connectionId, sessionId, cursor: clientLastSeq }); + } + + /** + * Start periodic sync — retries missed events for all connections. + * Runs every SYNC_INTERVAL_MS, bounded by SYNC_BATCH_LIMIT per connection. + * Call this once during server startup after setEventStore(). + */ + startPeriodicSync(): void { + if (this.syncTimer) { + log.warn('periodic sync already running'); + return; + } + if (!this.eventStore) { + log.error('cannot start periodic sync: EventStore not set'); + return; + } + + log.info('starting periodic sync', { intervalMs: SYNC_INTERVAL_MS }); + + this.syncTimer = setInterval(() => { + if (!this.eventStore) return; + + for (const [connectionId, conn] of this.connections.entries()) { + if (!conn.transport.isOpen()) continue; + + const connCursors = this.cursors.get(connectionId); + if (!connCursors) continue; + + for (const sessionId of conn.watchedSessions) { + const cursor = connCursors.get(sessionId) ?? 0; + + // Fetch missed events from EventStore + let missedEvents: Array<{ seq: number; payload: Record }>; + try { + missedEvents = this.eventStore.getEventsAfter(sessionId, cursor, SYNC_BATCH_LIMIT); + } catch (err) { + log.warn('periodic sync: EventStore fetch failed', { + connectionId, + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + + if (missedEvents.length === 0) continue; + + log.info('periodic sync: retrying missed events', { + connectionId, + sessionId, + cursor, + missedCount: missedEvents.length, + }); + + // Retry delivery + for (const evt of missedEvents) { + try { + conn.transport.send({ ...evt.payload, seq: evt.seq }); + // Update cursor on success + const current = connCursors.get(sessionId) ?? 0; + if (evt.seq > current) { + connCursors.set(sessionId, evt.seq); + } + } catch { + // Still failing — stop here, retry next sync round + log.warn('periodic sync: retry failed, stopping batch', { + connectionId, + sessionId, + failedSeq: evt.seq, + }); + break; + } + } + } + } + }, SYNC_INTERVAL_MS); + } + + /** + * Stop periodic sync and clean up timer. Call during graceful shutdown. + */ + stopPeriodicSync(): void { + if (this.syncTimer) { + clearInterval(this.syncTimer); + this.syncTimer = null; + log.info('periodic sync stopped'); + } + } + + /** + * Dispose: stop sync, clear all state. Used for graceful shutdown. + */ + dispose(): void { + this.stopPeriodicSync(); + this.connections.clear(); + this.cursors.clear(); + } } diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index fc312784..bb4250bc 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -13,7 +13,7 @@ export type { // Connection registry (v2 single-WS protocol) export { ConnectionRegistry } from './connection-registry.js'; -export type { Connection } from './connection-registry.js'; +export type { Connection, EventStoreAdapter } from './connection-registry.js'; // SSE registry (broadcast events) export { SseRegistry } from './sse-registry.js'; diff --git a/server/index.ts b/server/index.ts index 7fab751c..49c6e44f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -89,6 +89,9 @@ const nativeCommands = new NativeCommandRegistry(); const connRegistry = new ConnectionRegistry(); setConnectionRegistry(connRegistry); +// Wire up EventStore for periodic sync (enables delivery guarantee) +connRegistry.setEventStore(eventStore); + // Resolve cert paths relative to the project root (where package.json lives) const __filename = fileURLToPath(import.meta.url); const PROJECT_ROOT = join(__filename, '..', '..'); @@ -832,6 +835,7 @@ function shutdown(signal: string) { healthMonitor.destroy(); overviewEmitter.destroy(); sseRegistry.destroy(); + connRegistry.dispose(); // Stop periodic sync + clear state registry.dispose(); for (const client of wss.clients) { client.close(1001, 'Server shutting down'); @@ -854,6 +858,10 @@ checkPort(PORT).then((inUse) => { server.listen(PORT, () => { const protocol = USE_TLS ? 'https' : 'http'; log.info(`Chat Agent running on ${protocol}://localhost:${PORT}${USE_TLS ? ' (TLS)' : ''}`); + + // Start periodic sync for connection-level delivery guarantee + connRegistry.startPeriodicSync(); + // Eagerly reconcile sessions so the first /api/sessions request is fast and accurate. reconcileSessionsBackground(); // Clean up stale worktrees across all repos. diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index f47dfbc1..f7b90705 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -135,6 +135,11 @@ export function handleReconnect( } as Record); } + // Reset cursor to last replayed seq — prevents duplicate delivery from + // periodic sync. If no events replayed, cursor stays at client's lastSeq. + const newCursor = events.length > 0 ? events[events.length - 1].seq : entry.lastSeq; + ctx.connRegistry.resetCursor(connectionId, entry.sessionId, newCursor); + // Cross-reference with the durable EventStore: markSessionInactive() is // called in the query loop's finally block, so isActive=false in the // store is ground truth that the loop has ended. From 269028a5cfd31e9d8f87d565cf88745e9475a244 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sun, 3 May 2026 23:37:11 +0100 Subject: [PATCH 22/45] =?UTF-8?q?fix(harness):=20address=20PR=20#310=20rev?= =?UTF-8?q?iew=20findings=20=E2=80=94=20cursor=20race=20+=20session=20acti?= =?UTF-8?q?vity=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix two critical issues from code review: 1. Cursor race on reconnect: resetCursor is now called immediately after watch() and before replay, preventing periodic sync from seeing cursor=0 during the replay window and re-sending events the client already has. 2. Periodic sync session filtering: EventStoreAdapter gains an optional isSessionActive() callback. When provided, periodic sync skips ended sessions, avoiding unnecessary EventStore queries. server/index.ts wires this up via eventStore.getSession().isActive. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/connection-registry.test.ts | 54 +++++++++++++++++++ packages/harness/src/connection-registry.ts | 8 +++ server/__tests__/ws-handler-v2.test.ts | 34 ++++++++++++ server/index.ts | 9 +++- server/ws-handler-v2.ts | 4 ++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/harness/__tests__/connection-registry.test.ts b/packages/harness/__tests__/connection-registry.test.ts index 1984ab1c..fb2ab27d 100644 --- a/packages/harness/__tests__/connection-registry.test.ts +++ b/packages/harness/__tests__/connection-registry.test.ts @@ -452,6 +452,60 @@ describe('ConnectionRegistry', () => { vi.advanceTimersByTime(5000); expect(t.send).not.toHaveBeenCalled(); }); + + it('skips ended sessions when isSessionActive is provided', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1' } }, + { seq: 10, payload: { type: 'msg2' } }, + ]); + + // Add isSessionActive — sess-ended is inactive, sess-active is active + (store as EventStoreAdapter & { isSessionActive?: (id: string) => boolean }).isSessionActive = + (id: string) => id !== 'sess-ended'; + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-ended'); + registry.watch('conn-1', 'sess-active'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should only fetch events for sess-active, not sess-ended + const calls = (store.getEventsAfter as ReturnType).mock.calls; + const sessionIds = calls.map((c: unknown[]) => c[0]); + expect(sessionIds).toContain('sess-active'); + expect(sessionIds).not.toContain('sess-ended'); + + registry.stopPeriodicSync(); + }); + + it('still syncs all sessions when isSessionActive is not provided', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1' } }, + ]); + + // No isSessionActive — backwards compatible + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.watch('conn-1', 'sess-b'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch events for both sessions (no filtering) + const calls = (store.getEventsAfter as ReturnType).mock.calls; + const sessionIds = calls.map((c: unknown[]) => c[0]); + expect(sessionIds).toContain('sess-a'); + expect(sessionIds).toContain('sess-b'); + + registry.stopPeriodicSync(); + }); }); describe('dispose', () => { diff --git a/packages/harness/src/connection-registry.ts b/packages/harness/src/connection-registry.ts index a84fb606..33470ab6 100644 --- a/packages/harness/src/connection-registry.ts +++ b/packages/harness/src/connection-registry.ts @@ -38,6 +38,9 @@ export interface EventStoreAdapter { seq: number; payload: Record; }>; + /** Optional: check if a session is still active. When provided, periodic sync + * skips ended sessions to avoid unnecessary EventStore queries. */ + isSessionActive?(sessionId: string): boolean; } // Periodic sync fires every 5s to retry missed events @@ -218,6 +221,11 @@ export class ConnectionRegistry { if (!connCursors) continue; for (const sessionId of conn.watchedSessions) { + // Skip ended sessions to avoid unnecessary EventStore queries + if (this.eventStore.isSessionActive && !this.eventStore.isSessionActive(sessionId)) { + continue; + } + const cursor = connCursors.get(sessionId) ?? 0; // Fetch missed events from EventStore diff --git a/server/__tests__/ws-handler-v2.test.ts b/server/__tests__/ws-handler-v2.test.ts index bfcb30e3..62b3cf1e 100644 --- a/server/__tests__/ws-handler-v2.test.ts +++ b/server/__tests__/ws-handler-v2.test.ts @@ -283,6 +283,40 @@ describe('handleReconnect', () => { expect(reattachChat).not.toHaveBeenCalled(); }); + + it('resets cursor to client lastSeq immediately after watch (before replay)', () => { + const eventStore = mockEventStore(); + eventStore.getEventsAfter.mockReturnValue([ + { seq: 51, payload: { type: 'msg1' } }, + { seq: 52, payload: { type: 'msg2' } }, + ]); + + const ctx = createContext({ + eventStore: eventStore as unknown as V2HandlerContext['eventStore'], + }); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + // Spy on resetCursor to verify it's called early + const resetSpy = vi.spyOn(ctx.connRegistry, 'resetCursor'); + + handleReconnect( + 'c1', + { type: 'reconnect', sessions: [{ sessionId: 'sess-1', lastSeq: 50 }] }, + ctx, + ); + + // resetCursor should have been called at least twice: + // 1. Before replay (with client's lastSeq) + // 2. After replay (with final replayed seq) + expect(resetSpy).toHaveBeenCalledTimes(2); + // First call sets cursor to client's lastSeq + expect(resetSpy).toHaveBeenNthCalledWith(1, 'c1', 'sess-1', 50); + // Second call sets cursor to last replayed seq + expect(resetSpy).toHaveBeenNthCalledWith(2, 'c1', 'sess-1', 52); + + resetSpy.mockRestore(); + }); }); // ─── handleWatch / handleUnwatch ───────────────────────────────────────────── diff --git a/server/index.ts b/server/index.ts index 49c6e44f..2d8ef3c6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -89,8 +89,13 @@ const nativeCommands = new NativeCommandRegistry(); const connRegistry = new ConnectionRegistry(); setConnectionRegistry(connRegistry); -// Wire up EventStore for periodic sync (enables delivery guarantee) -connRegistry.setEventStore(eventStore); +// Wire up EventStore for periodic sync (enables delivery guarantee). +// Provide isSessionActive so periodic sync skips ended sessions. +connRegistry.setEventStore({ + getEventsAfter: (sessionId, afterSeq, limit) => + eventStore.getEventsAfter(sessionId, afterSeq, limit), + isSessionActive: (sessionId) => eventStore.getSession(sessionId)?.isActive ?? false, +}); // Resolve cert paths relative to the project root (where package.json lives) const __filename = fileURLToPath(import.meta.url); diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index f7b90705..01c83e25 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -127,6 +127,10 @@ export function handleReconnect( for (const entry of msg.sessions) { ctx.connRegistry.watch(connectionId, entry.sessionId); + // Set cursor to client's lastSeq BEFORE replay, so periodic sync + // sees a reasonable cursor during replay instead of 0. + ctx.connRegistry.resetCursor(connectionId, entry.sessionId, entry.lastSeq); + const events = ctx.eventStore.getEventsAfter(entry.sessionId, entry.lastSeq); for (const evt of events) { ctx.connRegistry.get(connectionId)?.transport.send({ From f45135d03effafa77d249928f2976e054708cba9 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Wed, 6 May 2026 09:52:55 +0100 Subject: [PATCH 23/45] feat(ui): add desktop page navigation and fix New Chat button (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop layout had no way to navigate to non-chat pages (Calendar, Inbox, Telos, Tasks, Files). Adds DesktopNav component in the left sidebar and wraps non-chat routes in DesktopShell via PageRoute. Also fixes the New Chat button which was broken because history.replaceState desynchronized React Router — handleNewChat now calls storeNewSession() directly. Co-authored-by: Claude Opus 4.6 --- frontend/src/App.tsx | 31 ++++++++--- frontend/src/components/DesktopNav.tsx | 48 +++++++++++++++++ frontend/src/components/DesktopShell.tsx | 38 +++++++------ .../__tests__/DesktopShell.test.tsx | 13 +++-- frontend/src/pages/DesktopChatView.tsx | 5 +- frontend/src/styles/desktop.css | 54 +++++++++++++++++++ 6 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/DesktopNav.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e32d28b..cf2e9ef1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { TodoDetailView } from './pages/TodoDetailView'; import { TaskBoard } from './pages/TaskBoard'; import { ErrorBoundary } from './components/ErrorBoundary'; import { MobileShell } from './components/MobileShell'; +import { DesktopShell } from './components/DesktopShell'; import { useIsDesktop } from './hooks/useMediaQuery'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -41,6 +42,12 @@ function ChatRoute() { return isDesktop ? : ; } +function PageRoute({ children }: { children: React.ReactNode }) { + const isDesktop = useIsDesktop(); + if (!isDesktop) return <>{children}; + return ; +} + function dismissKeyboard(e: React.MouseEvent | React.TouchEvent) { const target = e.target as HTMLElement; const tag = target.tagName; @@ -96,7 +103,9 @@ export function App() { path="/inbox" element={ - + + + } /> @@ -104,7 +113,9 @@ export function App() { path="/calendar" element={ - + + + } /> @@ -112,7 +123,9 @@ export function App() { path="/todos" element={ - + + + } /> @@ -120,7 +133,9 @@ export function App() { path="/todos/:id" element={ - + + + } /> @@ -128,7 +143,9 @@ export function App() { path="/tasks" element={ - + + + } /> @@ -137,7 +154,9 @@ export function App() { element={ - + + + } diff --git a/frontend/src/components/DesktopNav.tsx b/frontend/src/components/DesktopNav.tsx new file mode 100644 index 00000000..1eae98f2 --- /dev/null +++ b/frontend/src/components/DesktopNav.tsx @@ -0,0 +1,48 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { useTabBadges } from '../hooks/useTabBadges'; + +interface NavItem { + label: string; + path: string; + match: (pathname: string) => boolean; + badge?: number; +} + +export function DesktopNav() { + const location = useLocation(); + const navigate = useNavigate(); + const { inboxCount, todoCount } = useTabBadges(); + + const items: NavItem[] = [ + { + label: 'Chat', + path: '/', + match: (p) => p === '/' || p === '/chat' || p.startsWith('/chat/'), + }, + { label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') }, + { label: 'Inbox', path: '/inbox', match: (p) => p === '/inbox', badge: inboxCount }, + { + label: 'Telos', + path: '/todos', + match: (p) => p === '/todos' || p.startsWith('/todos/'), + badge: todoCount, + }, + { label: 'Tasks', path: '/tasks', match: (p) => p.startsWith('/tasks') }, + { label: 'Files', path: '/files', match: (p) => p.startsWith('/files') }, + ]; + + return ( + + ); +} diff --git a/frontend/src/components/DesktopShell.tsx b/frontend/src/components/DesktopShell.tsx index 229bdd08..7ac0fcd5 100644 --- a/frontend/src/components/DesktopShell.tsx +++ b/frontend/src/components/DesktopShell.tsx @@ -1,9 +1,10 @@ import { useState, useCallback, type ReactNode } from 'react'; +import { DesktopNav } from './DesktopNav'; export interface DesktopShellProps { - left: ReactNode; + left?: ReactNode; center: ReactNode; - right: ReactNode; + right?: ReactNode; statusBar?: ReactNode; } @@ -55,25 +56,32 @@ export function DesktopShell({ left, center, right, statusBar }: DesktopShellPro - {!leftCollapsed && left} + {!leftCollapsed && ( + <> + + {left} + + )}
{center}
-
- - {!rightCollapsed && right} -
+ + {!rightCollapsed && right} +
+ )} {statusBar &&
{statusBar}
} diff --git a/frontend/src/components/__tests__/DesktopShell.test.tsx b/frontend/src/components/__tests__/DesktopShell.test.tsx index 42625613..3670b255 100644 --- a/frontend/src/components/__tests__/DesktopShell.test.tsx +++ b/frontend/src/components/__tests__/DesktopShell.test.tsx @@ -1,6 +1,11 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; + +vi.mock('../DesktopNav', () => ({ + DesktopNav: () => , +})); + import { DesktopShell } from '../DesktopShell'; beforeEach(() => { @@ -31,7 +36,7 @@ describe('DesktopShell', () => { right={
Right
} />, ); - const toggleBtn = screen.getByTitle('Hide sessions'); + const toggleBtn = screen.getByTitle('Hide sidebar'); fireEvent.click(toggleBtn); expect(screen.queryByText('Left Panel')).toBeNull(); expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('1'); @@ -61,7 +66,7 @@ describe('DesktopShell', () => { />, ); expect(screen.queryByText('Left Panel')).toBeNull(); - expect(screen.getByTitle('Show sessions')).toBeTruthy(); + expect(screen.getByTitle('Show sidebar')).toBeTruthy(); }); it('expands collapsed sidebar on toggle', () => { @@ -73,7 +78,7 @@ describe('DesktopShell', () => { right={
Right
} />, ); - fireEvent.click(screen.getByTitle('Show sessions')); + fireEvent.click(screen.getByTitle('Show sidebar')); expect(screen.getByText('Left Panel')).toBeTruthy(); expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('0'); }); diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index 11f23b5e..b520448a 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -202,7 +202,10 @@ export function DesktopChatView() { }, []); const handleSelectSession = useCallback((id: string) => navigate(`/chat/${id}`), [navigate]); - const handleNewChat = useCallback(() => navigate('/chat'), [navigate]); + const handleNewChat = useCallback(() => { + storeNewSession(); + navigate('/chat'); + }, [navigate, storeNewSession]); return ( Date: Sat, 9 May 2026 14:35:53 +0100 Subject: [PATCH 24/45] fix(protocol,client): resolve subagent type mismatches breaking CI (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(protocol,client): resolve subagent type mismatches breaking CI StreamingBlock.subagent was typed as StreamingSubagentState only, but the SUBAGENT_END reducer replaces it with a FinishedSubagentState (subagent finishes while parent message is still streaming). Also, finishCurrent() copied the streaming subagent without conversion, and SubagentCard imported types not exported from @mitzo/protocol. - Widen StreamingBlock.subagent to accept both streaming and finished - Export FinishedSubagentState, StreamingSubagentState, SubagentState, SubagentUsage from @mitzo/protocol - Add finishSubagent() helper that handles both states (Map→array for streaming, passthrough for already-finished) - Cast to StreamingSubagentState in streaming reducer cases where the subagent is known to still be running Co-Authored-By: Claude Opus 4.6 * fix(client): replace unsafe casts with narrowing guards, fix test types Address Centaur review findings on PR #312: - Replace 5 `as StreamingSubagentState` casts with `'blockOrder' in` narrowing guards that safely bail if the subagent is already finished - Fix test type errors: cast to StreamingSubagentState/FinishedSubagentState in assertions so tests pass tsc --noEmit (not just esbuild) - Add two tests for finishSubagent: passthrough path (already-finished) and Map→array conversion path (still-streaming at MESSAGE_END) Co-Authored-By: Claude Opus 4.6 * style: fix prettier formatting in connection-registry test and query-loop Co-Authored-By: Claude Opus 4.6 * refactor(client): extract getStreamingSubagent helper, defensive filters Address Centaur review round 2: - Extract getStreamingSubagent() helper to DRY the 5 repeated 'blockOrder' in narrowing guards into a single function - Replace non-null assertions (!) with .filter(Boolean) in both finishSubagent and SUBAGENT_END — handles stale blockOrder entries - Add test: stale blockId in blockOrder is safely skipped - Add test: streaming events after SUBAGENT_END are silently dropped - Update existing finishCurrent test to verify Map→array conversion Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../client/__tests__/messages-slice.test.ts | 11 +- .../client/__tests__/subagent-reducer.test.ts | 261 ++++++++++++++++-- packages/client/src/slices/messages.ts | 133 +++++---- .../__tests__/connection-registry.test.ts | 4 +- packages/protocol/src/index.ts | 4 + packages/protocol/src/types.ts | 2 +- server/query-loop.ts | 5 +- 7 files changed, 336 insertions(+), 84 deletions(-) diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index a80e25af..43503f12 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; import type { MessagesState } from '../src/slices/messages.js'; -import type { FinishedBlock, StreamingMessage } from '@mitzo/protocol'; +import type { FinishedBlock, FinishedSubagentState, StreamingMessage } from '@mitzo/protocol'; const INITIAL = INITIAL_MESSAGES_STATE; @@ -1180,8 +1180,13 @@ describe('finishCurrent', () => { }; const finished = finishCurrent(current); - expect(finished.blocks[0].subagent).toBeDefined(); - expect(finished.blocks[0].subagent!.messageId).toBe('sub-msg-1'); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('sub-msg-1'); + // Verify streaming Map was converted to finished array + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('subagent output'); }); it('works normally when subagent field is absent', () => { diff --git a/packages/client/__tests__/subagent-reducer.test.ts b/packages/client/__tests__/subagent-reducer.test.ts index cc96a8ae..2910bc45 100644 --- a/packages/client/__tests__/subagent-reducer.test.ts +++ b/packages/client/__tests__/subagent-reducer.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { messagesReducer, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; -import type { StreamingBlock } from '@mitzo/protocol'; +import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; +import type { + StreamingBlock, + StreamingSubagentState, + FinishedSubagentState, +} from '@mitzo/protocol'; describe('Subagent Reducer Actions', () => { it('SUBAGENT_START initializes subagent state on parent tool block', () => { @@ -33,10 +37,11 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - expect(newState.current?.blocks.get('b1')?.subagent).toBeDefined(); - expect(newState.current?.blocks.get('b1')?.subagent?.messageId).toBe('msg-sub-1'); - expect(newState.current?.blocks.get('b1')?.subagent?.running).toBe(true); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.size).toBe(0); + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('msg-sub-1'); + expect(sub.running).toBe(true); + expect(sub.blocks.size).toBe(0); }); it('SUBAGENT_BLOCK_START adds block to subagent state', () => { @@ -76,11 +81,10 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.size).toBe(1); - expect(newState.current?.blocks.get('b1')?.subagent?.blockOrder).toEqual(['b-sub-1']); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.blockType).toBe( - 'thinking', - ); + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.size).toBe(1); + expect(sub.blockOrder).toEqual(['b-sub-1']); + expect(sub.blocks.get('b-sub-1')?.blockType).toBe('thinking'); }); it('SUBAGENT_BLOCK_DELTA appends to subagent block content', () => { @@ -130,9 +134,8 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.content).toBe( - 'Hello world', - ); + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-1')?.content).toBe('Hello world'); }); it('SUBAGENT_BLOCK_END marks subagent block as done', () => { @@ -181,7 +184,8 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.done).toBe(true); + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-1')?.done).toBe(true); }); it('SUBAGENT_TOOL_RESULT patches tool result in subagent block', () => { @@ -234,12 +238,9 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-2')?.toolResult).toBe( - 'file contents', - ); - expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-2')?.toolError).toBe( - false, - ); + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-2')?.toolResult).toBe('file contents'); + expect(sub.blocks.get('b-sub-2')?.toolError).toBe(false); }); it('SUBAGENT_END finalizes subagent state with summary and usage', () => { @@ -294,10 +295,218 @@ describe('Subagent Reducer Actions', () => { const newState = messagesReducer(state, action); - const subagent = newState.current?.blocks.get('b1')?.subagent; - expect(subagent?.running).toBeUndefined(); - expect(Array.isArray(subagent?.blocks)).toBe(true); - expect(subagent?.summary).toBe('Search complete'); - expect(subagent?.usage?.inputTokens).toBe(100); + const sub = newState.current?.blocks.get('b1')?.subagent as FinishedSubagentState; + expect(sub.running).toBeUndefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.summary).toBe('Search complete'); + expect(sub.usage?.inputTokens).toBe(100); + }); + + it('finishCurrent converts already-finished subagent via passthrough', () => { + // Simulate full lifecycle: SUBAGENT_START → blocks → SUBAGENT_END → MESSAGE_END + let state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Done', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // End the subagent first + state = messagesReducer(state, { + type: 'SUBAGENT_END', + parentBlockId: 'b1', + summary: 'All done', + }); + + // Now finish the message — finishSubagent should passthrough the already-finished state + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('Done'); + expect(sub.summary).toBe('All done'); + }); + + it('finishCurrent converts still-streaming subagent to finished', () => { + // Edge case: MESSAGE_END arrives without SUBAGENT_END + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Partial', + done: false, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // Finish message without SUBAGENT_END — finishSubagent must convert Map→array + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('Partial'); + expect(sub.summary).toBeUndefined(); + }); + + it('finishSubagent skips stale blockOrder entries not in the Map', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + // blockOrder references 'ghost' which is NOT in the Map + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Real', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1', 'ghost'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // Should not throw — stale entry is filtered out + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].blockId).toBe('b-sub-1'); + }); + + it('drops streaming events after SUBAGENT_END (out-of-order delivery)', () => { + let state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Done', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // End the subagent + state = messagesReducer(state, { + type: 'SUBAGENT_END', + parentBlockId: 'b1', + summary: 'Complete', + }); + + // Late-arriving streaming event should be silently dropped + const afterDelta = messagesReducer(state, { + type: 'SUBAGENT_BLOCK_DELTA', + parentBlockId: 'b1', + blockId: 'b-sub-1', + delta: ' extra', + }); + + // State unchanged — event was dropped by getStreamingSubagent guard + expect(afterDelta).toBe(state); }); }); diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index 5bb378bf..ee17be3e 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -13,6 +13,8 @@ import type { PermissionRequest, RawToolInput, BlockType, + StreamingSubagentState, + FinishedSubagentState, } from '@mitzo/protocol'; // ─── State ─────────────────────────────────────────────────────────────────── @@ -139,6 +141,39 @@ export type MessagesAction = // ─── Helpers ───────────────────────────────────────────────────────────────── +/** Narrow a block's subagent to StreamingSubagentState, or null if already finished. */ +function getStreamingSubagent(block: StreamingBlock): StreamingSubagentState | null { + if (!block.subagent || !('blockOrder' in block.subagent)) return null; + return block.subagent; +} + +function finishSubagent( + sub: StreamingSubagentState | FinishedSubagentState, +): FinishedSubagentState { + // Already finished (SUBAGENT_END already fired) + if (Array.isArray(sub.blocks)) return sub as FinishedSubagentState; + + // Still streaming — convert Map to FinishedBlock[] + const streaming = sub as StreamingSubagentState; + return { + messageId: streaming.messageId, + blocks: streaming.blockOrder + .map((blockId) => streaming.blocks.get(blockId)) + .filter((b): b is StreamingBlock => b != null) + .map((b) => ({ + blockId: b.blockId, + blockType: b.blockType, + content: b.content, + toolName: b.toolName, + toolId: b.toolId, + toolInput: b.toolInput, + rawInput: b.rawInput, + toolResult: b.toolResult, + toolError: b.toolError, + })), + }; +} + export function finishCurrent(current: StreamingMessage): FinishedMessage { const blocks: FinishedBlock[] = current.blockOrder.map((blockId) => { const b = current.blocks.get(blockId)!; @@ -152,7 +187,7 @@ export function finishCurrent(current: StreamingMessage): FinishedMessage { rawInput: b.rawInput, toolResult: b.toolResult, toolError: b.toolError, - subagent: b.subagent, + subagent: b.subagent ? finishSubagent(b.subagent) : undefined, }; }); return { messageId: current.messageId, role: 'assistant', blocks, timestamp: Date.now() }; @@ -527,7 +562,9 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SUBAGENT_BLOCK_START': { if (!state.current) return state; const parentBlock = state.current.blocks.get(action.parentBlockId); - if (!parentBlock?.subagent) return state; + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; const newBlock: StreamingBlock = { blockId: action.blockId, @@ -537,16 +574,16 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M ...(action.toolName ? { toolName: action.toolName } : {}), }; - const newSubBlocks = new Map(parentBlock.subagent.blocks); + const newSubBlocks = new Map(sub.blocks); newSubBlocks.set(action.blockId, newBlock); const newBlocks = new Map(state.current.blocks); newBlocks.set(action.parentBlockId, { ...parentBlock, subagent: { - ...parentBlock.subagent, + ...sub, blocks: newSubBlocks, - blockOrder: [...parentBlock.subagent.blockOrder, action.blockId], + blockOrder: [...sub.blockOrder, action.blockId], }, }); @@ -556,12 +593,14 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SUBAGENT_BLOCK_DELTA': { if (!state.current) return state; const parentBlock = state.current.blocks.get(action.parentBlockId); - if (!parentBlock?.subagent) return state; + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; - const subBlock = parentBlock.subagent.blocks.get(action.blockId); + const subBlock = sub.blocks.get(action.blockId); if (!subBlock) return state; - const newSubBlocks = new Map(parentBlock.subagent.blocks); + const newSubBlocks = new Map(sub.blocks); newSubBlocks.set(action.blockId, { ...subBlock, content: subBlock.content + action.delta, @@ -570,10 +609,7 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M const newBlocks = new Map(state.current.blocks); newBlocks.set(action.parentBlockId, { ...parentBlock, - subagent: { - ...parentBlock.subagent, - blocks: newSubBlocks, - }, + subagent: { ...sub, blocks: newSubBlocks }, }); return { ...state, current: { ...state.current, blocks: newBlocks } }; @@ -582,12 +618,14 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SUBAGENT_BLOCK_END': { if (!state.current) return state; const parentBlock = state.current.blocks.get(action.parentBlockId); - if (!parentBlock?.subagent) return state; + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; - const subBlock = parentBlock.subagent.blocks.get(action.blockId); + const subBlock = sub.blocks.get(action.blockId); if (!subBlock) return state; - const newSubBlocks = new Map(parentBlock.subagent.blocks); + const newSubBlocks = new Map(sub.blocks); newSubBlocks.set(action.blockId, { ...subBlock, done: true, @@ -600,10 +638,7 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M const newBlocks = new Map(state.current.blocks); newBlocks.set(action.parentBlockId, { ...parentBlock, - subagent: { - ...parentBlock.subagent, - blocks: newSubBlocks, - }, + subagent: { ...sub, blocks: newSubBlocks }, }); return { ...state, current: { ...state.current, blocks: newBlocks } }; @@ -612,12 +647,14 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SUBAGENT_TOOL_RESULT': { if (!state.current) return state; const parentBlock = state.current.blocks.get(action.parentBlockId); - if (!parentBlock?.subagent) return state; + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; // Find the tool block with matching toolId - for (const [blockId, subBlock] of parentBlock.subagent.blocks) { + for (const [blockId, subBlock] of sub.blocks) { if (subBlock.toolId === action.toolId) { - const newSubBlocks = new Map(parentBlock.subagent.blocks); + const newSubBlocks = new Map(sub.blocks); newSubBlocks.set(blockId, { ...subBlock, toolResult: action.result, @@ -627,10 +664,7 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M const newBlocks = new Map(state.current.blocks); newBlocks.set(action.parentBlockId, { ...parentBlock, - subagent: { - ...parentBlock.subagent, - blocks: newSubBlocks, - }, + subagent: { ...sub, blocks: newSubBlocks }, }); return { ...state, current: { ...state.current, blocks: newBlocks } }; @@ -643,34 +677,33 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SUBAGENT_END': { if (!state.current) return state; const parentBlock = state.current.blocks.get(action.parentBlockId); - if (!parentBlock?.subagent) return state; + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; // Convert streaming subagent state to finished state - const finishedBlocks: FinishedBlock[] = parentBlock.subagent.blockOrder.map((blockId) => { - const b = parentBlock.subagent!.blocks.get(blockId)!; - return { - blockId: b.blockId, - blockType: b.blockType, - content: b.content, - toolName: b.toolName, - toolId: b.toolId, - toolInput: b.toolInput, - rawInput: b.rawInput, - toolResult: b.toolResult, - toolError: b.toolError, - }; - }); + const finished: FinishedSubagentState = { + messageId: sub.messageId, + blocks: sub.blockOrder + .map((blockId) => sub.blocks.get(blockId)) + .filter((b): b is StreamingBlock => b != null) + .map((b) => ({ + blockId: b.blockId, + blockType: b.blockType, + content: b.content, + toolName: b.toolName, + toolId: b.toolId, + toolInput: b.toolInput, + rawInput: b.rawInput, + toolResult: b.toolResult, + toolError: b.toolError, + })), + summary: action.summary, + usage: action.usage, + }; const newBlocks = new Map(state.current.blocks); - newBlocks.set(action.parentBlockId, { - ...parentBlock, - subagent: { - messageId: parentBlock.subagent.messageId, - blocks: finishedBlocks, - summary: action.summary, - usage: action.usage, - }, - }); + newBlocks.set(action.parentBlockId, { ...parentBlock, subagent: finished }); return { ...state, current: { ...state.current, blocks: newBlocks } }; } diff --git a/packages/harness/__tests__/connection-registry.test.ts b/packages/harness/__tests__/connection-registry.test.ts index fb2ab27d..6c4cd851 100644 --- a/packages/harness/__tests__/connection-registry.test.ts +++ b/packages/harness/__tests__/connection-registry.test.ts @@ -485,9 +485,7 @@ describe('ConnectionRegistry', () => { it('still syncs all sessions when isSessionActive is not provided', async () => { vi.useFakeTimers(); const t = mockTransport(true); - const store = mockEventStore([ - { seq: 5, payload: { type: 'msg1' } }, - ]); + const store = mockEventStore([{ seq: 5, payload: { type: 'msg1' } }]); // No isSessionActive — backwards compatible registry.setEventStore(store); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 174b0ecc..f8fcc1c5 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -27,6 +27,10 @@ export type { SessionActivity, ServiceHealthStatus, ServiceHealthPayload, + StreamingSubagentState, + FinishedSubagentState, + SubagentState, + SubagentUsage, } from './types.js'; // Constants diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 44dfeb14..d597e61e 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -50,7 +50,7 @@ export interface StreamingBlock { rawInput?: RawToolInput; toolResult?: string; toolError?: boolean; - subagent?: StreamingSubagentState; + subagent?: StreamingSubagentState | FinishedSubagentState; } export interface StreamingMessage { diff --git a/server/query-loop.ts b/server/query-loop.ts index 5e3d74fd..785f6ad5 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -698,7 +698,10 @@ async function _runQueryLoopInner( const index = evt.index as number; const blockId = nextBlockId(); const blockType = contentBlock?.type as string | undefined; - subagent.subagentBlockIdByIndex.set(index, { blockId, blockType: blockType ?? 'text' }); + subagent.subagentBlockIdByIndex.set(index, { + blockId, + blockType: blockType ?? 'text', + }); if (blockType === 'thinking' || blockType === 'redacted_thinking') { emit( From cd8d5938aaa0a0d5aff89c00cd331720cc9cef27 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Sat, 9 May 2026 14:42:19 +0100 Subject: [PATCH 25/45] feat(ui): task board redesign with attention sorting (#314) * feat(ui): task board redesign with state-colored cards and attention sorting Contextual loop controls, attend-tier sorting, progressive fade for completed tasks, token usage display, and show-all toggle. Co-Authored-By: Claude Opus 4.6 * fix(ui): address code review items on task board redesign - Fix timer to activate for all tasks, not just done tasks, so active task elapsed times update every 60s - Pass `now` parameter to computeFadeOpacity instead of calling Date.now() directly, preserving the memoization pattern - Remove shallow hasDone check (was only 2 levels deep); timer now activates whenever tasks.length > 0, making depth irrelevant - Remove unused attendCounts from hook return and its supporting code - Show context lines for tier-1 children (pending_review/blocked/failed) instead of always suppressing them with compact={true} Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/components/LoopControls.tsx | 143 +++++++---- frontend/src/components/SessionOverview.tsx | 15 +- frontend/src/components/TaskNode.tsx | 112 +++++++-- frontend/src/hooks/useTaskBoard.ts | 145 ++++++++++- frontend/src/lib/formatTime.ts | 9 + frontend/src/pages/TaskBoard.tsx | 16 +- frontend/src/styles/global.css | 260 +++++++++++++++----- 7 files changed, 545 insertions(+), 155 deletions(-) diff --git a/frontend/src/components/LoopControls.tsx b/frontend/src/components/LoopControls.tsx index 0ada829f..d005d80a 100644 --- a/frontend/src/components/LoopControls.tsx +++ b/frontend/src/components/LoopControls.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import type { LoopStatus } from '../types/task'; import type { Task } from '../types/task'; +import { formatTokens } from '../lib/formatTokens'; interface LoopControlsProps { loopStatus: LoopStatus; goals: Task[]; + totalTokenUsage: number; onStart: (goalId: string, specMode?: boolean) => void; onPause: () => void; onResume: () => void; @@ -13,15 +15,10 @@ interface LoopControlsProps { onRejectSpec: () => void; } -const STATE_LABELS: Record = { - idle: 'Idle', - running: 'Running', - paused: 'Paused', -}; - export function LoopControls({ loopStatus, goals, + totalTokenUsage, onStart, onPause, onResume, @@ -29,26 +26,27 @@ export function LoopControls({ onApproveSpec, onRejectSpec, }: LoopControlsProps) { + const [pickerOpen, setPickerOpen] = useState(false); const [selectedGoalId, setSelectedGoalId] = useState(''); const [specMode, setSpecMode] = useState(false); const { state, progress, awaitingApproval } = loopStatus; - return ( -
-
- - {STATE_LABELS[state]} - - {progress && ( - - {progress.done}/{progress.total} - - )} -
+ // ── Idle: compact trigger / expanded picker ── + if (state === 'idle') { + if (!pickerOpen) { + return ( +
+ +
+ ); + } - {state === 'idle' && ( -
+ return ( +
+
setNewSummary(e.target.value)} + placeholder="New item..." + autoFocus + onKeyDown={(e) => { + if (e.key === 'Escape') setCreating(false); + }} + /> + + + )} + + {loading &&

Loading...

} + + {!loading && items.length === 0 &&

No active items

} + + {focus.length > 0 && ( +
+
Focus ({focus.length})
+ {focus.map((item) => ( + + ))} +
+ )} + + {active.length > 0 && ( +
+
Active ({active.length})
+ {active.map((item) => ( + + ))} +
+ )} + + {seen.length > 0 && ( +
+
Seen ({seen.length})
+ {seen.map((item) => ( + + ))} +
+ )} + + ); +} diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index b520448a..cd5c734b 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -1,10 +1,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { apiFetch } from '../lib/api-fetch'; import { DesktopShell } from '../components/DesktopShell'; import { SessionPanel } from '../components/SessionPanel'; -import { ContextPanel } from '../components/ContextPanel'; -import { FileBrowserPanel } from '../components/FileBrowserPanel'; +import { CommandCenter } from '../components/CommandCenter'; import { ChatArea } from '../components/ChatArea'; import { ChatInput } from '../components/ChatInput'; import { ScrollFab } from '../components/ScrollFab'; @@ -16,8 +14,6 @@ import { getPreferredModel, setPreferredModel } from '../lib/model-preference'; import { useVoice } from '../hooks/useVoice'; import { useAutoSpeak } from '../hooks/useAutoSpeak'; import { useProgressByToolId } from '../hooks/useProgress'; -import type { FileRoot } from '../components/FileBrowserPanel'; -import type { ContextBlockEntry } from '../components/ContextPicker'; import type { ImageAttachment } from '../types/chat'; export function DesktopChatView() { @@ -25,32 +21,6 @@ export function DesktopChatView() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [contextBlocks, setContextBlocks] = useState([]); - - // Shared config fetch for right-panel children - const [configBlocks, setConfigBlocks] = useState([]); - const [fileRoots, setFileRoots] = useState([]); - const [configLoaded, setConfigLoaded] = useState(false); - - useEffect(() => { - apiFetch('/api/config') - .then((r) => r.json()) - .then((data) => { - const entries: ContextBlockEntry[] = []; - if (data.contextBlocks) { - for (const [name, info] of Object.entries( - data.contextBlocks as Record, - )) { - entries.push({ name, path: info.path, sizeBytes: info.sizeBytes }); - } - } - setConfigBlocks(entries); - setFileRoots(data.fileViewerRoots ?? []); - setConfigLoaded(true); - }) - .catch(() => setConfigLoaded(true)); - }, []); - // Store state const messages = useMessages(); const connection = useConnection(); @@ -149,10 +119,6 @@ export function DesktopChatView() { // ── Actions ────────────────────────────────────────────────────────────── function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean { - // For new sessions (no activeSessionId) the store bootstraps a WS on - // demand inside sendMessage(), so we must not block on connection status. - // Only gate on connection for existing sessions where a WS should already - // be open. if (activeSessionId && connection.status !== 'connected') { storeDispatchMessages({ type: 'CONNECTION_LOST' }); return false; @@ -195,12 +161,6 @@ export function DesktopChatView() { storeSetMode(newMode); } - const handleToggleContext = useCallback((name: string) => { - setContextBlocks((prev) => - prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], - ); - }, []); - const handleSelectSession = useCallback((id: string) => navigate(`/chat/${id}`), [navigate]); const handleNewChat = useCallback(() => { storeNewSession(); @@ -291,22 +251,11 @@ export function DesktopChatView() { isWorktree={messages.isWorktree} wtId={messages.wtId || undefined} sessionId={activeSessionId ?? undefined} - externalContextBlocks={contextBlocks} tokenState={tokens} />
} - right={ -
- - -
- } + right={} statusBar={ Date: Sat, 9 May 2026 15:27:11 +0100 Subject: [PATCH 27/45] feat(ui): boot context pill at session start (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): boot context pill — show ContexGin compilation metadata at session start Threads ContexGin compile result from server through protocol to a tappable pill at the top of the chat. Shows source count, token count, and compilation engine (green dot for ContexGin, amber for local fallback). Expands to list source files and trimmed section count. Co-Authored-By: Claude Opus 4.6 * fix(ui): address PR #313 review findings — desktop view, validation, tests - Wire bootContext + sessionContext to DesktopChatView (was missing) - Server: async/await with try/catch, validate compiled shape, log errors - Protocol parser: runtime validation instead of bare `as` casts - CSS: define --status-ok/--status-warn vars, use class modifiers - Add 6 tests: SET_BOOT_CONTEXT reducer (3) + boot_context parser (3) Co-Authored-By: Claude Opus 4.6 * fix(ui): address Centaur review items on boot context pill - Separate import vs compilation try/catch in server boot context IIFE - Add runtime shape validation for contexgin compile() return value - Validate individual source entries before sending over WS - Filter non-string elements from sources array in protocol parser - Fix empty-state overlap when bootContext is present - Use index-based React keys for source list items - Add tests for sources validation edge cases (non-string, non-array) Co-Authored-By: Claude Opus 4.6 * style: fix prettier formatting in client index Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/components/BootContextPill.tsx | 43 +++++++ frontend/src/components/ChatArea.tsx | 8 +- frontend/src/pages/ChatView.tsx | 2 + frontend/src/pages/DesktopChatView.tsx | 4 + frontend/src/styles/global.css | 85 +++++++++++++ .../client/__tests__/messages-slice.test.ts | 49 ++++++++ .../client/__tests__/protocol-parser.test.ts | 117 ++++++++++++++++++ packages/client/src/index.ts | 7 +- packages/client/src/protocol-parser.ts | 17 +++ packages/client/src/slices/messages.ts | 14 +++ server/chat.ts | 79 +++++++++++- 11 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/BootContextPill.tsx diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx new file mode 100644 index 00000000..21acee65 --- /dev/null +++ b/frontend/src/components/BootContextPill.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import type { BootContextMeta } from '@mitzo/client'; + +interface Props { + context: BootContextMeta; +} + +export function BootContextPill({ context }: Props) { + const [expanded, setExpanded] = useState(false); + + const isContexgin = context.source === 'contexgin'; + const dotClass = isContexgin ? 'boot-context-pill-dot--ok' : 'boot-context-pill-dot--warn'; + const tokenLabel = + context.tokenCount >= 1000 + ? `${(context.tokenCount / 1000).toFixed(1)}k` + : String(context.tokenCount); + const label = `${context.sourceCount} sources \u00b7 ${tokenLabel} tokens`; + + return ( +
+ + {expanded && ( +
+ {context.sources.map((src, idx) => ( +
+ {src} +
+ ))} + {context.trimmedCount > 0 && ( +
+ {context.trimmedCount} section{context.trimmedCount !== 1 ? 's' : ''} trimmed +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index edccfc77..dafba8cc 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -4,6 +4,7 @@ import { ThinkingBlock } from './ThinkingBlock'; import { ToolPill } from './ToolPill'; import { ToolGroup } from './ToolGroup'; import { ContextBlock } from './ContextBlock'; +import { BootContextPill } from './BootContextPill'; import { PermissionBanner } from './PermissionBanner'; import { ProgressWidget } from './ProgressWidget'; import { groupBlocks } from '../lib/groupMessages'; @@ -15,6 +16,7 @@ import type { PermissionRequest, } from '../types/chat'; import type { ProgressBlock } from '@mitzo/protocol'; +import type { BootContextMeta } from '@mitzo/client'; import type { UseVoiceReturn } from '../hooks/useVoice'; export type ChatAreaVoice = Pick< @@ -36,6 +38,8 @@ export interface ChatAreaProps { scrollRef?: React.RefObject; /** Boot context for sessions started from inbox/todo items */ sessionContext?: string | null; + /** Boot context compilation metadata from ContexGin */ + bootContext?: BootContextMeta | null; /** Progress blocks indexed by toolId for rendering ProgressWidget on TodoWrite blocks */ progressByToolId?: Record; /** Voice capabilities for per-block read-aloud */ @@ -50,6 +54,7 @@ export function ChatArea({ onPermissionRespond, scrollRef: externalScrollRef, sessionContext, + bootContext, progressByToolId, voice, }: ChatAreaProps) { @@ -147,9 +152,10 @@ export function ChatArea({ onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > + {bootContext && } {sessionContext && } - {messages.length === 0 && !current && !running && !sessionContext && ( + {messages.length === 0 && !current && !running && !sessionContext && !bootContext && (

Send a message to start

)} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 74fb8b86..5f42f588 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -51,6 +51,7 @@ export function ChatView() { const pendingSession = useMitzoStore((s) => s.pendingSession); const clearPendingSession = useMitzoStore((s) => s.clearPendingSession); const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -267,6 +268,7 @@ export function ChatView() { onPermissionRespond={handlePermission} scrollRef={scrollRef} sessionContext={sessionContext} + bootContext={bootContext} progressByToolId={progressByToolId} voice={voice} /> diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index cd5c734b..94a81355 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -38,6 +38,8 @@ export function DesktopChatView() { const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); const storeFetchSessionMeta = useMitzoStore((s) => s.fetchSessionMeta); + const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -236,6 +238,8 @@ export function DesktopChatView() { permission={messages.permission} onPermissionRespond={handlePermission} scrollRef={scrollRef} + sessionContext={sessionContext} + bootContext={bootContext} progressByToolId={progressByToolId} voice={voice} /> diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index d20f066a..2e87ce08 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -23,6 +23,8 @@ --hover: rgba(108, 99, 255, 0.1); --active: rgba(108, 99, 255, 0.2); --warning: #ff9800; + --status-ok: #4ade80; + --status-warn: #fbbf24; --shadow: rgba(0, 0, 0, 0.3); /* Task state colors */ @@ -2293,6 +2295,89 @@ textarea:focus { opacity: 0.7; } +/* ===== Boot Context Pill (ContexGin metadata) ===== */ + +.boot-context-pill { + align-self: flex-start; + width: 100%; + border-radius: 6px; + overflow: hidden; + margin-bottom: 0.3rem; +} + +.boot-context-pill-header { + display: flex; + align-items: center; + gap: 0.4rem; + touch-action: manipulation; + padding: 0.25rem 0.6rem; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + font-size: var(--text-xxs); + font-family: inherit; + color: var(--text-dim); + min-width: 0; +} + +.boot-context-pill-header:active { + background: rgba(255, 255, 255, 0.03); +} + +.boot-context-pill-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.boot-context-pill-dot--ok { + background: var(--status-ok); +} + +.boot-context-pill-dot--warn { + background: var(--status-warn); +} + +.boot-context-pill-label { + font-size: var(--text-xxs); +} + +.boot-context-pill-engine { + font-size: var(--text-xxs); + opacity: 0.5; +} + +.boot-context-pill-chevron { + margin-left: auto; + font-size: 0.6rem; + opacity: 0.5; +} + +.boot-context-pill-content { + padding: 0.3rem 0.6rem 0.3rem 1.2rem; + font-family: var(--code-font); + font-size: var(--text-xxs); + line-height: 1.5; + color: var(--text-dim); + opacity: 0.7; +} + +.boot-context-pill-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.boot-context-pill-trimmed { + margin-top: 0.2rem; + font-style: italic; + color: var(--status-warn); +} + /* ===== Tool Group ===== */ .tool-group { diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index 43503f12..e0607946 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1228,3 +1228,52 @@ describe('NATIVE_COMMAND_RESULT', () => { expect(msg.blocks[0].content).toContain('Available skills'); }); }); + +// ─── SET_BOOT_CONTEXT ──────────────────────────────────────────────────────── + +describe('SET_BOOT_CONTEXT', () => { + it('sets bootContext from null', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }; + const state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + expect(state.bootContext).toEqual(meta); + }); + + it('overwrites existing bootContext', () => { + const first = { + source: 'local-fallback' as const, + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [] as string[], + }; + const second = { + source: 'contexgin' as const, + sourceCount: 3, + tokenCount: 1500, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: first }); + state = messagesReducer(state, { type: 'SET_BOOT_CONTEXT', bootContext: second }); + expect(state.bootContext).toEqual(second); + }); + + it('is cleared by CLEAR', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 2, + tokenCount: 1000, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + state = messagesReducer(state, { type: 'CLEAR' }); + expect(state.bootContext).toBeNull(); + }); +}); diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 03ef7f30..dbceb72a 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -702,3 +702,120 @@ describe('error with No conversation found', () => { expect(r.messagesActions).toContainEqual(expect.objectContaining({ type: 'ERROR' })); }); }); + +// ─── boot_context ───────────────────────────────────────────────────────────── + +describe('boot_context', () => { + it('maps boot_context to SET_BOOT_CONTEXT with validated fields', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toEqual([ + { + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + }, + ]); + }); + + it('normalizes unknown source to local-fallback', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'unknown-engine', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { source: 'local-fallback' }, + }); + }); + + it('defaults missing numeric fields to 0 and sources to empty array', () => { + const r = parseServerMessage( + { type: 'boot_context', source: 'contexgin' }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toEqual({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + }); + }); + + it('filters out non-string elements from sources array', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 3, + tokenCount: 1000, + trimmedCount: 0, + sources: ['CLAUDE.md', 42, null, undefined, { relativePath: 'foo.md' }, 'README.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: ['CLAUDE.md', 'README.md'], + }, + }); + }); + + it('handles sources as a non-array value gracefully', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 1, + tokenCount: 500, + trimmedCount: 0, + sources: 'not-an-array', + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: [], + }, + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 55261765..5888b322 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,7 +17,12 @@ export type { } from './store.js'; // Slices — state shapes and types -export type { MessagesState, MessagesAction, ActiveWorktree } from './slices/messages.js'; +export type { + MessagesState, + MessagesAction, + ActiveWorktree, + BootContextMeta, +} from './slices/messages.js'; export { messagesReducer, INITIAL_MESSAGES_STATE } from './slices/messages.js'; export type { SessionsState } from './slices/sessions.js'; export type { ConnectionState, ConnectionStatus } from './slices/connection.js'; diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 09ea1acc..067a538e 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -194,6 +194,23 @@ export function parseServerMessage( }); break; + case 'boot_context': { + const source = msg.source === 'contexgin' ? 'contexgin' : 'local-fallback'; + const sourceCount = typeof msg.sourceCount === 'number' ? msg.sourceCount : 0; + const tokenCount = typeof msg.tokenCount === 'number' ? msg.tokenCount : 0; + const trimmedCount = typeof msg.trimmedCount === 'number' ? msg.trimmedCount : 0; + // Validate each element is a string — filter out non-string entries + const rawSources = Array.isArray(msg.sources) ? msg.sources : []; + const sources: string[] = rawSources.filter( + (s: unknown): s is string => typeof s === 'string', + ); + result.messagesActions.push({ + type: 'SET_BOOT_CONTEXT', + bootContext: { source, sourceCount, tokenCount, trimmedCount, sources }, + }); + break; + } + case 'worktree_opened': result.messagesActions.push({ type: 'WORKTREE_OPENED', diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index ee17be3e..ad67dd25 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -24,6 +24,14 @@ export interface ActiveWorktree { path: string; } +export interface BootContextMeta { + source: 'contexgin' | 'local-fallback'; + sourceCount: number; + tokenCount: number; + trimmedCount: number; + sources: string[]; +} + export interface MessagesState { messages: FinishedMessage[]; current: StreamingMessage | null; @@ -34,6 +42,7 @@ export interface MessagesState { wtId: string | null; activeWorktrees: ActiveWorktree[]; sessionContext: string | null; + bootContext: BootContextMeta | null; } export const INITIAL_MESSAGES_STATE: MessagesState = { @@ -46,6 +55,7 @@ export const INITIAL_MESSAGES_STATE: MessagesState = { wtId: null, activeWorktrees: [], sessionContext: null, + bootContext: null, }; // ─── Actions ───────────────────────────────────────────────────────────────── @@ -137,6 +147,7 @@ export type MessagesAction = | { type: 'WORKTREE_OPENED'; repoName: string; path: string } | { type: 'NATIVE_COMMAND_RESULT'; command: string; content: string } | { type: 'SET_SESSION_CONTEXT'; context: string } + | { type: 'SET_BOOT_CONTEXT'; bootContext: BootContextMeta } | { type: 'CLEAR' }; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -411,6 +422,9 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SET_SESSION_CONTEXT': return { ...state, sessionContext: action.context }; + case 'SET_BOOT_CONTEXT': + return { ...state, bootContext: action.bootContext }; + case 'CLEAR': return { ...INITIAL_MESSAGES_STATE }; diff --git a/server/chat.ts b/server/chat.ts index e07093cb..148ad70c 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -728,7 +728,84 @@ async function _startChatInner( buildWorktreeSystemPrompt(repoWorktrees) + buildTaskPromptForSession(clientId); - // Fire-and-forget: capture prompt comparison for the experiments spoke + // Fire-and-forget: emit boot context metadata to client + capture prompt comparison + (async () => { + // Step 1: dynamically import contexgin (optional dependency) + let compileModule: { + compile: (opts: { workspaceRoot: string; tokenBudget: number }) => Promise; + }; + try { + compileModule = await import('contexgin'); + } catch (importErr: unknown) { + const msg = importErr instanceof Error ? importErr.message : String(importErr); + log.info('contexgin not available, using fallback', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + // Step 2: compile — runtime errors propagate (not swallowed as import failure) + try { + const compiled = await compileModule.compile({ workspaceRoot: cwd, tokenBudget: 8000 }); + + // Validate the compiled object shape + if (!compiled || typeof compiled !== 'object') { + log.warn('contexgin compile() returned unexpected shape', { compiled }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + const obj = compiled as Record; + const sources = Array.isArray(obj.sources) ? obj.sources : []; + const trimmed = Array.isArray(obj.trimmed) ? obj.trimmed : []; + const bootTokens = typeof obj.bootTokens === 'number' ? obj.bootTokens : 0; + + // Validate each source entry has a relativePath string + const sourcePaths: string[] = []; + for (const s of sources) { + if ( + s && + typeof s === 'object' && + typeof (s as Record).relativePath === 'string' + ) { + sourcePaths.push((s as Record).relativePath as string); + } + } + + send(transport, { + type: 'boot_context', + source: 'contexgin', + sourceCount: sourcePaths.length, + tokenCount: bootTokens, + trimmedCount: trimmed.length, + sources: sourcePaths, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.warn('boot context compilation failed', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + } + })(); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); try { From cfe3af2b70e344125e82a4496eaae54421e5d491 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 14 May 2026 09:18:13 +0100 Subject: [PATCH 28/45] fix(harness): cascade stopTask to subagents on interrupt (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(protocol,client): resolve subagent type mismatches breaking build StreamingBlock.subagent was typed as StreamingSubagentState but the SUBAGENT_END reducer transitions it to FinishedSubagentState. Changed to SubagentState union. Added finishSubagent() helper for proper Map→array conversion. Exported subagent types from protocol index. Co-Authored-By: Claude Opus 4.6 * fix(harness): cascade stopTask to subagents on interrupt interruptChat() only called interrupt() on the parent query, which was blocked waiting for a running subagent — causing the session to hang. Now tracks task_id from SDK task_started events on the session, and calls stopTask() for each active subagent before interrupting the parent. The frontend receives subagent_cancelled and renders it as a finished block with "Cancelled" summary. Co-Authored-By: Claude Opus 4.6 * fix(harness): address PR review — union type, tests, debug logging - Add SubagentCancelledMsg to ServerMessage union type (critical bug) - Add debug log when task_started has missing fields - Add test: subagent_cancelled → SUBAGENT_END mapping in protocol-parser - Add test: stopTask cascade in interruptChat with active tasks Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/types/ws-messages.ts | 13 +++++- .../client/__tests__/protocol-parser.test.ts | 26 +++++++++++ packages/client/src/protocol-parser.ts | 8 ++++ packages/harness/src/session-registry.ts | 10 ++++- server/__tests__/send-to-chat.test.ts | 44 ++++++++++++++++++- server/chat.ts | 11 +++++ server/query-loop.ts | 40 ++++++++++++++++- 7 files changed, 147 insertions(+), 5 deletions(-) diff --git a/frontend/src/types/ws-messages.ts b/frontend/src/types/ws-messages.ts index bfe099cd..a16d8fe1 100644 --- a/frontend/src/types/ws-messages.ts +++ b/frontend/src/types/ws-messages.ts @@ -239,7 +239,8 @@ export type ServerMessage = | SubagentBlockDeltaMsg | SubagentBlockEndMsg | SubagentToolResultMsg - | SubagentEndMsg; + | SubagentEndMsg + | SubagentCancelledMsg; export interface ProgressStartMsg { type: 'progress_start'; @@ -368,3 +369,13 @@ export interface SubagentEndMsg { cacheCreationTokens: number; }; } + +export interface SubagentCancelledMsg { + type: 'subagent_cancelled'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + taskId: string; +} diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index dbceb72a..a59d42f6 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -819,3 +819,29 @@ describe('boot_context', () => { }); }); }); + +// ─── Subagent cancellation ─────────────────────────────────────────────────── + +describe('subagent_cancelled', () => { + it('maps to SUBAGENT_END with summary Cancelled', () => { + const r = parseServerMessage( + { + type: 'subagent_cancelled', + v: 2, + ts: Date.now(), + parentBlockId: 'blk-parent-1', + subagentMessageId: 'msg-sub-1', + taskId: 'task-123', + } as any, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toHaveLength(1); + expect(r.messagesActions[0]).toEqual({ + type: 'SUBAGENT_END', + parentBlockId: 'blk-parent-1', + summary: 'Cancelled', + }); + }); +}); diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 067a538e..0b9bb541 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -521,6 +521,14 @@ export function parseServerMessage( | undefined, }); break; + + case 'subagent_cancelled': + result.messagesActions.push({ + type: 'SUBAGENT_END', + parentBlockId: msg.parentBlockId as string, + summary: 'Cancelled', + }); + break; } return result; diff --git a/packages/harness/src/session-registry.ts b/packages/harness/src/session-registry.ts index 17698433..f7ae08dd 100644 --- a/packages/harness/src/session-registry.ts +++ b/packages/harness/src/session-registry.ts @@ -28,7 +28,11 @@ export interface ManagedSession { worktreePath?: string; /** All worktrees created for this session, keyed by repo name. */ worktreePaths: Map; - queryInstance?: { interrupt: () => Promise; close: () => void }; + queryInstance?: { + interrupt: () => Promise; + close: () => void; + stopTask: (taskId: string) => Promise; + }; inputQueue?: { push: (msg: unknown) => void; close: () => void }; currentSnapshot: MessageSnapshot | null; activeSkillPolicy: Set | null; @@ -38,6 +42,8 @@ export interface ManagedSession { cumulativeCostUsd: number; taskContext: { currentTaskId: string; goalId: string } | null; telosTaskId?: string; + /** Active subagent task IDs — task_id → tool_use_id (parent_tool_use_id). */ + activeTaskIds: Map; } export interface ActiveSessionInfo { @@ -89,6 +95,7 @@ export class SessionRegistry { | 'cumulativeSessionTokens' | 'cumulativeCostUsd' | 'taskContext' + | 'activeTaskIds' > & { sessionId?: string; }, @@ -102,6 +109,7 @@ export class SessionRegistry { cumulativeSessionTokens: 0, cumulativeCostUsd: 0, taskContext: null, + activeTaskIds: new Map(), }); this.attached.add(clientId); } diff --git a/server/__tests__/send-to-chat.test.ts b/server/__tests__/send-to-chat.test.ts index 1c4e04b2..558a87de 100644 --- a/server/__tests__/send-to-chat.test.ts +++ b/server/__tests__/send-to-chat.test.ts @@ -136,7 +136,11 @@ describe('interruptChat emits user_message via transport', () => { const session = registry.get(CLIENT_ID)!; session.sessionId = 'sess-int-1'; session.inputQueue = { push: pushSpy, close: vi.fn() }; - session.queryInstance = { interrupt: vi.fn().mockResolvedValue(undefined), close: vi.fn() }; + session.queryInstance = { + interrupt: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + stopTask: vi.fn().mockResolvedValue(undefined), + }; const result = await interruptChat(CLIENT_ID, 'Urgent message'); expect(result).toBe(true); @@ -165,7 +169,11 @@ describe('interruptChat emits user_message via transport', () => { const session = registry.get(CLIENT_ID)!; session.sessionId = 'sess-int-2'; session.inputQueue = { push: pushSpy, close: vi.fn() }; - session.queryInstance = { interrupt: vi.fn().mockResolvedValue(undefined), close: vi.fn() }; + session.queryInstance = { + interrupt: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + stopTask: vi.fn().mockResolvedValue(undefined), + }; const result = await interruptChat(CLIENT_ID, 'Urgent', undefined, undefined, 'user-5678-def'); expect(result).toBe(true); @@ -176,4 +184,36 @@ describe('interruptChat emits user_message via transport', () => { expect(userMsgEvents).toHaveLength(1); expect((userMsgEvents[0] as Record).messageId).toBe('user-5678-def'); }); + + it('calls stopTask for active subagent tasks before interrupt', async () => { + const transport = mockTransport(); + const pushSpy = vi.fn(); + const stopTaskSpy = vi.fn().mockResolvedValue(undefined); + const interruptSpy = vi.fn().mockResolvedValue(undefined); + + registry.register(CLIENT_ID, { + transport, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + const session = registry.get(CLIENT_ID)!; + session.sessionId = 'sess-int-3'; + session.inputQueue = { push: pushSpy, close: vi.fn() }; + session.queryInstance = { + interrupt: interruptSpy, + close: vi.fn(), + stopTask: stopTaskSpy, + }; + session.activeTaskIds.set('task-abc', 'tool-1'); + session.activeTaskIds.set('task-def', 'tool-2'); + + await interruptChat(CLIENT_ID, 'Stop everything'); + + expect(stopTaskSpy).toHaveBeenCalledTimes(2); + expect(stopTaskSpy).toHaveBeenCalledWith('task-abc'); + expect(stopTaskSpy).toHaveBeenCalledWith('task-def'); + expect(interruptSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/chat.ts b/server/chat.ts index 148ad70c..6e012de9 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1006,6 +1006,17 @@ export async function interruptChat( const echo = { type: 'user_message', messageId, text: fullPrompt }; send(session.transport, echo); broadcastToObservers(session.observers, echo); + // Stop all active subagent tasks before interrupting the parent query. + // Without this, interrupt() only halts the parent — which is blocked + // waiting for the subagent, so the session hangs. + if (session.activeTaskIds.size > 0) { + const stops = [...session.activeTaskIds.keys()].map((taskId) => + session + .queryInstance!.stopTask(taskId) + .catch((err: unknown) => log.warn('stopTask failed', { taskId, err })), + ); + await Promise.allSettled(stops); + } await session.queryInstance.interrupt(); session.inputQueue.push(makeUserMessage(fullPrompt, 'now')); return true; diff --git a/server/query-loop.ts b/server/query-loop.ts index 785f6ad5..b41c3db5 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -186,6 +186,7 @@ async function _runQueryLoopInner( { parentBlockId: string; parentToolName: string; + taskId: string | null; subagentMessageId: string | null; subagentBlockIdByIndex: Map; subagentToolInputBuffers: Map; @@ -590,6 +591,7 @@ async function _runQueryLoopInner( activeSubagents.set(parentToolUseId, { parentBlockId, parentToolName: 'Agent', // We don't track this persistently, assume Agent + taskId: null, subagentMessageId, subagentBlockIdByIndex: new Map(), subagentToolInputBuffers: new Map(), @@ -1060,13 +1062,49 @@ async function _runQueryLoopInner( tryFlushMessageEnd(currentSession); } } else if (msg.type === 'system') { - // Track compaction events from SDK system status messages const subtype = (msg as Record).subtype; + + // Track compaction events from SDK system status messages const compactResult = (msg as Record).compact_result; if (subtype === 'status' && compactResult === 'success') { numCompactions++; log.info('compaction completed', { clientId, numCompactions }); } + + // Track subagent task lifecycle for interrupt cancellation + if (subtype === 'task_started') { + const taskId = (msg as Record).task_id as string | undefined; + const toolUseId = (msg as Record).tool_use_id as string | undefined; + if (taskId && toolUseId) { + currentSession?.activeTaskIds.set(taskId, toolUseId); + const subagent = activeSubagents.get(toolUseId); + if (subagent) subagent.taskId = taskId; + log.info('subagent task started', { clientId, taskId, toolUseId }); + } else { + log.debug('task_started missing fields', { clientId, taskId, toolUseId }); + } + } else if (subtype === 'task_notification') { + const taskId = (msg as Record).task_id as string | undefined; + const status = (msg as Record).status as string | undefined; + const toolUseId = taskId ? currentSession?.activeTaskIds.get(taskId) : undefined; + if (taskId) { + currentSession?.activeTaskIds.delete(taskId); + log.info('subagent task finished', { clientId, taskId, status }); + } + if (status === 'stopped' && toolUseId) { + const subagent = activeSubagents.get(toolUseId); + if (subagent) { + emit( + v2('subagent_cancelled', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + taskId, + }), + ); + activeSubagents.delete(toolUseId); + } + } + } } else if (msg.type === 'user') { // Only extract tool_result events from SDK user turns. // Do NOT emit user_message here — human input is persisted at the From 11b67fb923d80892a8738bd061a8601da5569324 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 14 May 2026 12:40:34 +0100 Subject: [PATCH 29/45] feat(ui): inline markdown preview cards in chat (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add inline markdown preview cards in chat messages Markdown file paths (.md/.mdx) now render as collapsible preview cards instead of plain links. Cards lazy-load content on expand via /api/files/read and render with ReactMarkdown. Solo-paragraph links become cards; inline references stay as links. "Open" button navigates to FileViewer for editing. Co-Authored-By: Claude Opus 4.6 * fix(ui): address PR review — valid HTML structure and clarifying comment Restructure card header so toggle and "Open" are sibling buttons instead of nesting inside + +
+ {expanded && ( +
+ {loading &&

Loading...

} + {error &&

{error}

} + {content !== null && ( +
+ {content} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index cc8b906b..c2edba60 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -8,6 +8,7 @@ import { formatTime } from '../lib/formatTime'; import { CopyButton } from './CopyButton'; import { ReadAloudButton } from './ReadAloudButton'; import { extractText } from '../lib/extractText'; +import { MarkdownPreviewCard } from './MarkdownPreviewCard'; const COLLAPSE_HEIGHT = 300; @@ -120,6 +121,21 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
); }, + // When a paragraph contains a single file-path link to a .md/.mdx + // file, promote it to an inline preview card instead of a plain link. + // The `a` handler passes the resolved path via data-file-path; this + // handler checks the extension and decides whether to promote. + p: ({ children }) => { + const childArray = React.Children.toArray(children); + if (childArray.length === 1 && React.isValidElement(childArray[0])) { + const el = childArray[0] as React.ReactElement>; + const filePath = el.props?.['data-file-path'] as string | undefined; + if (filePath && /\.mdx?$/i.test(filePath)) { + return ; + } + } + return

{children}

; + }, a: ({ href, children }) => { if (href?.startsWith(FILE_SCHEME)) { const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); @@ -127,6 +143,7 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
{ e.preventDefault(); navigate( diff --git a/frontend/src/components/__tests__/MessageBubble.test.ts b/frontend/src/components/__tests__/MessageBubble.test.ts index 914544fe..d2e36a9f 100644 --- a/frontend/src/components/__tests__/MessageBubble.test.ts +++ b/frontend/src/components/__tests__/MessageBubble.test.ts @@ -220,6 +220,52 @@ describe('TextBubble code block CopyButton', () => { }); }); +describe('TextBubble markdown preview card promotion', () => { + it('provides a custom p component to ReactMarkdown', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + expect(capturedComponents).toBeDefined(); + expect(capturedComponents!.p).toBeDefined(); + }); + + it('promotes a standalone .md file-path link to MarkdownPreviewCard', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + // Simulate what the a handler returns for a .md file-path link + const link = createElement('a', { 'data-file-path': '/tmp/notes.md' }, '/tmp/notes.md'); + + const result = p({ children: link }); + // Should return a MarkdownPreviewCard, not a

+ expect(result.type).not.toBe('p'); + expect(result.props.filePath).toBe('/tmp/notes.md'); + }); + + it('does not promote non-.md file-path links', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const link = createElement('a', { 'data-file-path': '/tmp/data.json' }, '/tmp/data.json'); + + const result = p({ children: link }); + expect(result.type).toBe('p'); + }); + + it('does not promote .md links when paragraph has other content', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const link = createElement('a', { 'data-file-path': '/tmp/notes.md' }, '/tmp/notes.md'); + + const result = p({ children: ['See ', link, ' for details'] }); + expect(result.type).toBe('p'); + }); + + it('a handler sets data-file-path on file-path links', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const anchor = capturedComponents!.a; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const rendered = anchor({ href: fileHref, children: '/tmp/notes.md' }); + expect(rendered.props['data-file-path']).toBe('/tmp/notes.md'); + }); +}); + describe('MessageBubble legacy adapter forwards timestamps', () => { it('forwards timestamp to UserBubble for user messages', () => { const ts = Date.now(); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 2e87ce08..326ebf63 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2378,6 +2378,208 @@ textarea:focus { color: var(--status-warn); } +/* ===== Markdown Preview Card ===== */ + +.md-preview-card { + align-self: flex-start; + width: 100%; + border-radius: 6px; + overflow: hidden; + border-left: 3px solid #7dd3fc; + margin: 0.4em 0; +} + +.md-preview-card-header { + display: flex; + align-items: center; + min-width: 0; +} + +.md-preview-card-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; + touch-action: manipulation; + padding: 0.35rem 0.6rem; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + color: var(--text); + font-size: var(--text-xs); + font-family: inherit; +} + +.md-preview-card-toggle:active { + background: rgba(125, 211, 252, 0.05); +} + +.md-preview-card-icon { + font-size: var(--text-xxs); + font-weight: 700; + color: #7dd3fc; + background: rgba(125, 211, 252, 0.12); + padding: 1px 4px; + border-radius: 3px; + flex-shrink: 0; + font-family: var(--code-font); +} + +.md-preview-card-name { + font-weight: 500; + color: var(--text); + font-family: var(--code-font); + font-size: var(--text-xxs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.md-preview-card-open { + font-size: var(--text-xxs); + color: var(--accent); + flex-shrink: 0; + padding: 2px 6px; + margin-right: 0.4rem; + border-radius: 3px; + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; + -webkit-tap-highlight-color: transparent; +} + +.md-preview-card-open:active { + background: rgba(99, 102, 241, 0.1); +} + +.md-preview-card-chevron { + color: var(--text-dim); + font-size: 0.6rem; + flex-shrink: 0; +} + +.md-preview-card-content { + border-top: 1px solid var(--border); + background: var(--code-bg); + border-radius: 0 0 6px 6px; +} + +.md-preview-card-status { + padding: 0.4rem 0.6rem; + font-size: var(--text-xxs); + color: var(--text-dim); + margin: 0; +} + +.md-preview-card-status--error { + color: #f8a0a0; +} + +.md-preview-card-body { + padding: 0.5rem 0.6rem; + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + font-size: var(--text-xs); + line-height: 1.5; + color: var(--text); +} + +.md-preview-card-body h1, +.md-preview-card-body h2, +.md-preview-card-body h3, +.md-preview-card-body h4 { + font-weight: 600; + margin: 0.6em 0 0.3em; + color: #fff; +} +.md-preview-card-body h1 { + font-size: 1.1em; +} +.md-preview-card-body h2 { + font-size: 1.05em; +} +.md-preview-card-body h3 { + font-size: 1em; +} +.md-preview-card-body h1:first-child, +.md-preview-card-body h2:first-child, +.md-preview-card-body h3:first-child { + margin-top: 0; +} +.md-preview-card-body p { + margin-bottom: 0.4em; +} +.md-preview-card-body p:last-child { + margin-bottom: 0; +} +.md-preview-card-body ul, +.md-preview-card-body ol { + padding-left: 1.2em; + margin-bottom: 0.4em; +} +.md-preview-card-body li { + margin-bottom: 0.15em; +} +.md-preview-card-body code { + font-family: var(--code-font); + font-size: 0.82em; + background: rgba(255, 255, 255, 0.06); + padding: 0.12em 0.3em; + border-radius: 3px; +} +.md-preview-card-body pre { + margin: 0.4em 0; + padding: 0.4rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 6px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.md-preview-card-body pre code { + background: transparent; + padding: 0; +} +.md-preview-card-body blockquote { + border-left: 2px solid var(--accent); + padding-left: 0.6em; + margin: 0.3em 0; + color: var(--text-dim); +} +.md-preview-card-body strong { + font-weight: 600; + color: #fff; +} +.md-preview-card-body a { + color: var(--accent); + text-decoration: underline; +} +.md-preview-card-body table { + border-collapse: collapse; + width: 100%; + font-size: 0.85em; + margin: 0.3em 0; +} +.md-preview-card-body th, +.md-preview-card-body td { + border: 1px solid var(--border); + padding: 0.25em 0.5em; +} +.md-preview-card-body th { + background: rgba(255, 255, 255, 0.04); + font-weight: 600; +} +.md-preview-card-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 0.5em 0; +} /* ===== Tool Group ===== */ .tool-group { From 7c051458833c88112d63926b46f3ac98ff0622b3 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 14 May 2026 13:38:17 +0100 Subject: [PATCH 30/45] feat(tracing): nested OTel spans for subagent hierarchy (#318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tracing): nested OTel spans for subagent hierarchy Create subagent, subagent.turn, and subagent.tool.* spans nested under the parent tool.Agent span. Jaeger now shows the full hierarchy: session → turn → tool.Agent → subagent → subagent.tool.*. Span attributes include subagent.parent_tool_id, subagent.message_id, subagent.duration_ms, and subagent.total_tokens. Cleanup in finally block ensures spans are always closed even on abnormal exit. Co-Authored-By: Claude Opus 4.6 * style(subagent-visibility): address Centaur review findings on PR #318 Extract endSubagentSpans() helper to deduplicate span cleanup between normal completion and finally block. Add caughtError flag so finally uses ERROR status on spans when catch was entered. Support multi-turn subagents with incrementing turnIndex. Warn when parent tool span is missing for subagent span parenting. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- server/query-loop.ts | 170 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 152 insertions(+), 18 deletions(-) diff --git a/server/query-loop.ts b/server/query-loop.ts index b41c3db5..e18da8b2 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -28,6 +28,52 @@ function truncateForTrace(text: string): { text: string; truncated?: true } { return { text: text.slice(0, TRACE_CONTENT_MAX_CHARS), truncated: true }; } +/** End all open OTel spans for a subagent entry. */ +function endSubagentSpans( + sub: { + span: Span | null; + turnSpan: Span | null; + toolSpans: Map; + startedAt: number; + usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + } | null; + }, + statusCode: typeof SpanStatusCode.OK | typeof SpanStatusCode.ERROR, +): void { + // End any open tool spans + for (const [, ts] of sub.toolSpans) { + ts.setStatus({ code: statusCode }); + ts.end(); + } + sub.toolSpans.clear(); + + // End turn span + if (sub.turnSpan) { + sub.turnSpan.setStatus({ code: statusCode }); + sub.turnSpan.end(); + } + + // End subagent span with attributes + if (sub.span) { + const durationMs = Date.now() - sub.startedAt; + sub.span.setAttribute('subagent.duration_ms', durationMs); + if (sub.usage) { + const totalTokens = + sub.usage.inputTokens + + sub.usage.outputTokens + + sub.usage.cacheReadTokens + + sub.usage.cacheCreationTokens; + sub.span.setAttribute('subagent.total_tokens', totalTokens); + } + sub.span.setStatus({ code: statusCode }); + sub.span.end(); + } +} + /** Send data via transport, guarding on isOpen(). */ function send(transport: SessionTransport, data: Record) { if (transport.isOpen()) transport.send(data); @@ -196,6 +242,12 @@ async function _runQueryLoopInner( cacheReadTokens: number; cacheCreationTokens: number; } | null; + // OTel span hierarchy for subagent + span: Span | null; + turnSpan: Span | null; + turnIndex: number; + toolSpans: Map; // blockId → tool span + startedAt: number; } >(); @@ -206,6 +258,7 @@ async function _runQueryLoopInner( const toolIdToBlockId = new Map(); let blockCounter = 0; + let caughtError = false; let currentMessageId: string | null = null; let doneSent = false; let openBlockCount = 0; @@ -378,10 +431,13 @@ async function _runQueryLoopInner( if (msg.type === 'assistant') { const parentToolUseId = msg.parent_tool_use_id as string | undefined; - // Subagent turn complete — emit subagent_end + // Subagent turn complete — emit subagent_end + close spans if (parentToolUseId) { const subagent = activeSubagents.get(parentToolUseId); if (subagent) { + const durationMs = Date.now() - subagent.startedAt; + endSubagentSpans(subagent, SpanStatusCode.OK); + emit( v2('subagent_end', { parentBlockId: subagent.parentBlockId, @@ -394,6 +450,7 @@ async function _runQueryLoopInner( parentToolId: parentToolUseId, subagentMessageId: subagent.subagentMessageId, usage: subagent.usage, + durationMs, }); activeSubagents.delete(parentToolUseId); } @@ -588,15 +645,60 @@ async function _runQueryLoopInner( const subagentMessageId = (apiMsg?.id as string | undefined) ?? `msg-${Date.now()}`; if (parentBlockId) { - activeSubagents.set(parentToolUseId, { - parentBlockId, - parentToolName: 'Agent', // We don't track this persistently, assume Agent - taskId: null, - subagentMessageId, - subagentBlockIdByIndex: new Map(), - subagentToolInputBuffers: new Map(), - usage: null, - }); + const existing = activeSubagents.get(parentToolUseId); + + if (existing) { + // Multi-turn subagent: end previous turn span, start a new one + if (existing.turnSpan) { + existing.turnSpan.setStatus({ code: SpanStatusCode.OK }); + existing.turnSpan.end(); + } + existing.turnIndex += 1; + existing.subagentMessageId = subagentMessageId; + existing.subagentBlockIdByIndex.clear(); + existing.subagentToolInputBuffers.clear(); + + const subTurnCtx = existing.span + ? trace.setSpan(context.active(), existing.span) + : context.active(); + existing.turnSpan = tracer.startSpan('subagent.turn', {}, subTurnCtx); + existing.turnSpan.setAttribute('subagent.turn.index', existing.turnIndex); + } else { + // First turn: create subagent span under the parent tool span + const parentToolSpan = toolSpans.get(parentBlockId); + if (!parentToolSpan) { + log.warn('subagent parent tool span not found', { + parentBlockId, + parentToolUseId, + }); + } + const subagentParentCtx = parentToolSpan + ? trace.setSpan(context.active(), parentToolSpan) + : context.active(); + const subagentSpan = tracer.startSpan('subagent', {}, subagentParentCtx); + subagentSpan.setAttribute('subagent.parent_tool_id', parentToolUseId); + subagentSpan.setAttribute('subagent.message_id', subagentMessageId); + + // Start first turn span under the subagent span + const subTurnCtx = trace.setSpan(context.active(), subagentSpan); + const subTurnSpan = tracer.startSpan('subagent.turn', {}, subTurnCtx); + subTurnSpan.setAttribute('subagent.turn.index', 0); + + activeSubagents.set(parentToolUseId, { + parentBlockId, + parentToolName: 'Agent', + taskId: null, + subagentMessageId, + subagentBlockIdByIndex: new Map(), + subagentToolInputBuffers: new Map(), + usage: null, + span: subagentSpan, + turnSpan: subTurnSpan, + turnIndex: 0, + toolSpans: new Map(), + startedAt: Date.now(), + }); + } // Extract usage from message_start const msgUsage = (apiMsg as Record | undefined)?.usage as @@ -611,17 +713,20 @@ async function _runQueryLoopInner( }; } - emit( - v2('subagent_start', { - parentBlockId, - parentToolId: parentToolUseId, - subagentMessageId, - }), - ); - log.info('subagent started', { + if (!existing) { + emit( + v2('subagent_start', { + parentBlockId, + parentToolId: parentToolUseId, + subagentMessageId, + }), + ); + } + log.info(existing ? 'subagent new turn' : 'subagent started', { clientId, parentToolId: parentToolUseId, subagentMessageId, + ...(existing ? { turnIndex: existing.turnIndex } : {}), }); } // Don't process parent turn logic for subagent message_start @@ -722,6 +827,20 @@ async function _runQueryLoopInner( id: toolId, inputBuf: '', }); + + // Create OTel span for subagent tool under its turn span + const subToolParent = subagent.turnSpan + ? trace.setSpan(context.active(), subagent.turnSpan) + : context.active(); + const subToolSpan = tracer.startSpan( + `subagent.tool.${toolName}`, + {}, + subToolParent, + ); + subToolSpan.setAttribute('tool.name', toolName); + subToolSpan.setAttribute('tool.id', toolId); + subagent.toolSpans.set(blockId, subToolSpan); + emit( v2('subagent_block_start', { parentBlockId: subagent.parentBlockId, @@ -935,6 +1054,14 @@ async function _runQueryLoopInner( ...(rawInput ? { rawInput } : {}), }), ); + // End subagent tool span + const subToolSpan = subagent.toolSpans.get(blockId); + if (subToolSpan) { + subToolSpan.setStatus({ code: SpanStatusCode.OK }); + subToolSpan.end(); + subagent.toolSpans.delete(blockId); + } + log.info('subagent tool call', { clientId, parentToolId: parentToolUseId, @@ -1175,6 +1302,7 @@ async function _runQueryLoopInner( } } } catch (err: unknown) { + caughtError = true; span.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : 'unknown', @@ -1219,6 +1347,12 @@ async function _runQueryLoopInner( if (store && resolvedSessionId) { store.markSessionInactive(resolvedSessionId); } + // Clean up any open subagent spans (ERROR if catch was entered) + const subagentCleanupStatus = caughtError ? SpanStatusCode.ERROR : SpanStatusCode.OK; + for (const [, sub] of activeSubagents) { + endSubagentSpans(sub, subagentCleanupStatus); + } + activeSubagents.clear(); // Clean up any open tool spans for (const [, ts] of toolSpans) { ts.setStatus({ code: SpanStatusCode.OK }); From 2f7c34a0e6f85902662f02881367c0071052f00e Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 14 May 2026 13:39:36 +0100 Subject: [PATCH 31/45] fix(client,server): prevent duplicate messages on iOS foreground recovery (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client,server): prevent duplicate messages on iOS foreground recovery Three fixes for the race where RESTORE and WS replay both populate messages[]: - MESSAGE_START/END: skip if messageId already exists (dedup guard) - replayEventsToMessages: deterministic initial prompt messageId - RESTORE: clear stale current when its messageId conflicts with restored set Tests cover unit-level dedup guards plus full foreground recovery sequence. Co-Authored-By: Claude Sonnet 4.5 * fix(client): address review feedback - style and perf fixes - MESSAGE_END early return: avoid re-render when current is null - Rename currentStaleI → currentStale for clarity - Use ternary instead of conditional spread for current field Co-Authored-By: Claude Sonnet 4.5 * fix(client): address Centaur review — style, perf, missing test - Rename currentStaleI → currentStale, use ternary instead of conditional spread - MESSAGE_END: return `state` instead of `{ ...state }` to avoid unnecessary re-renders - Add test: interrupted RESTORE with optimistic user msgs AND stale current Co-Authored-By: Claude Opus 4.6 * fix(client): add missing test for finishSubagent during MESSAGE_START dedup Adds test coverage for the finishCurrent/finishSubagent path exercised when MESSAGE_START arrives while current has streaming subagent blocks. Also fixes Prettier formatting. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Sonnet 4.5 --- .../client/__tests__/messages-slice.test.ts | 395 ++++++++++++++++++ packages/client/src/slices/messages.ts | 20 +- server/__tests__/reconstruct-messages.test.ts | 29 ++ server/chat.ts | 7 +- 4 files changed, 447 insertions(+), 4 deletions(-) diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index e0607946..5386c4e2 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1277,3 +1277,398 @@ describe('SET_BOOT_CONTEXT', () => { expect(state.bootContext).toBeNull(); }); }); + +// ─── Dedup: foreground recovery vs WS replay ──────────────────────────────── + +describe('MESSAGE_START dedup', () => { + it('skips if messageId already exists in finished messages', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'done' }], + }, + ], + }; + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-1' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + }); + + it('allows MESSAGE_START for a new messageId', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'done' }], + }, + ], + }; + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-2' }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-2'); + }); +}); + +describe('MESSAGE_END dedup', () => { + it('discards current without appending if messageId already in messages', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'restored' }], + }, + ], + current: { + messageId: 'msg-1', + blocks: new Map([ + ['b2', { blockId: 'b2', blockType: 'text', content: 'streaming', done: true }], + ]), + blockOrder: ['b2'], + }, + }; + const next = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'msg-1' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + expect(next.messages[0].blocks[0].content).toBe('restored'); + }); + + it('appends normally when messageId is new', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-2', + blocks: new Map([['b1', { blockId: 'b1', blockType: 'text', content: 'new', done: true }]]), + blockOrder: ['b1'], + }, + }; + const next = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'msg-2' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + expect(next.messages[0].messageId).toBe('msg-2'); + }); +}); + +describe('RESTORE clears stale current', () => { + it('nullifies current when its messageId is in the restored set', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-1', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + }); + + it('preserves current when its messageId is NOT in the restored set', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-new', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-old', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-new'); + }); + + it('nullifies current in interrupted RESTORE too', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-1', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored, interrupted: true }); + expect(next.current).toBeNull(); + }); + + it('interrupted RESTORE with optimistic user msgs AND stale current', () => { + // Simulate: user sent a follow-up (optimistic), assistant was streaming, + // then interrupted RESTORE arrives with the completed assistant message. + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'restored-1', + role: 'assistant' as const, + blocks: [{ blockId: 'a1', blockType: 'text' as const, content: 'first reply' }], + }, + { + messageId: 'user-optimistic', + role: 'user' as const, + blocks: [{ blockId: 'u1', blockType: 'text' as const, content: 'follow-up' }], + }, + ], + current: { + messageId: 'asst-2', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'restored-1', + role: 'assistant' as const, + blocks: [{ blockId: 'a1', blockType: 'text' as const, content: 'first reply' }], + }, + { + messageId: 'asst-2', + role: 'assistant' as const, + blocks: [{ blockId: 'a2', blockType: 'text' as const, content: 'second reply' }], + }, + ]; + const next = messagesReducer(state, { + type: 'RESTORE', + messages: restored, + interrupted: true, + }); + // current should be cleared (asst-2 is in the restored set) + expect(next.current).toBeNull(); + // optimistic user msg should be preserved and merged + const userMsgs = next.messages.filter((m) => m.role === 'user'); + expect(userMsgs).toHaveLength(1); + expect(userMsgs[0].messageId).toBe('user-optimistic'); + // restored assistant messages should be present + expect(next.messages.some((m) => m.messageId === 'asst-2')).toBe(true); + }); +}); + +describe('MESSAGE_START finalizes current with subagent blocks', () => { + it('finishCurrent converts streaming subagent to finished when new message starts', () => { + // Simulate: assistant is streaming a message with a subagent tool_use block, + // then a new MESSAGE_START arrives — the orphaned current should be finalized + // with subagent blocks correctly converted from Map to array. + const subagentBlocks = new Map([ + [ + 'sub-b1', + { + blockId: 'sub-b1', + blockType: 'text' as const, + content: 'subagent thinking', + done: true, + }, + ], + [ + 'sub-b2', + { + blockId: 'sub-b2', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Bash', + toolId: 'sub-tool-1', + toolInput: 'echo hi', + }, + ], + ]); + + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-with-subagent', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Agent', + toolId: 'agent-tool-1', + subagent: { + messageId: 'sub-msg-1', + blocks: subagentBlocks, + blockOrder: ['sub-b1', 'sub-b2'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // New MESSAGE_START should finalize the current (with subagent) and start fresh + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-new' }); + + // Orphaned message with subagent should be finalized into messages[] + expect(next.messages).toHaveLength(1); + expect(next.messages[0].messageId).toBe('msg-with-subagent'); + + // Subagent should be converted from streaming (Map) to finished (array) + const sub = next.messages[0].blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('sub-msg-1'); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(2); + expect(sub.blocks[0].content).toBe('subagent thinking'); + expect(sub.blocks[1].toolName).toBe('Bash'); + expect(sub.blocks[1].toolId).toBe('sub-tool-1'); + + // New current should be clean + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-new'); + expect(next.current!.blockOrder).toHaveLength(0); + }); +}); + +describe('foreground recovery race — full sequence', () => { + it('RESTORE then WS replay of same message does not duplicate', () => { + // Simulate: user sends, assistant streams, iOS backgrounds, foreground returns + // 1. USER_SEND adds optimistic user message + let state = messagesReducer(INITIAL, { + type: 'USER_SEND', + text: 'hello', + clientMsgId: 'user-abc', + }); + // 2. MESSAGE_START creates current + state = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-1' }); + state = messagesReducer(state, { + type: 'BLOCK_START', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + state = messagesReducer(state, { + type: 'BLOCK_DELTA', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + delta: 'partial', + }); + expect(state.messages).toHaveLength(1); // user msg + expect(state.current).not.toBeNull(); + + // 3. RESTORE fires (foreground recovery) — server has the completed conversation + state = messagesReducer(state, { + type: 'RESTORE', + messages: [ + { + messageId: 'user-abc', + role: 'user' as const, + blocks: [{ blockId: 'u1', blockType: 'text' as const, content: 'hello' }], + }, + { + messageId: 'asst-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'full response' }], + }, + ], + }); + // RESTORE should replace messages AND clear stale current + expect(state.messages).toHaveLength(2); + expect(state.current).toBeNull(); + + // 4. WS replay delivers the same message_start + message_end + state = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-1' }); + // MESSAGE_START should be a no-op (dedup) + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + + // 5. BLOCK events after suppressed MESSAGE_START are safe no-ops + state = messagesReducer(state, { + type: 'BLOCK_START', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + state = messagesReducer(state, { + type: 'BLOCK_DELTA', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + delta: 'replayed', + }); + state = messagesReducer(state, { + type: 'BLOCK_END', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + // Still no current, still 2 messages + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + + // 6. MESSAGE_END for the replayed message — should be safe no-op + state = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'asst-1' }); + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + expect(state.messages[1].blocks[0].content).toBe('full response'); + }); + + it('multi-turn: only the replayed message is deduplicated, earlier ones kept', () => { + // Start with a completed first turn + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'user-1', + role: 'user', + blocks: [{ blockId: 'u1', blockType: 'text', content: 'first' }], + }, + { + messageId: 'asst-1', + role: 'assistant', + blocks: [{ blockId: 'a1', blockType: 'text', content: 'reply 1' }], + }, + { + messageId: 'user-2', + role: 'user', + blocks: [{ blockId: 'u2', blockType: 'text', content: 'second' }], + }, + { + messageId: 'asst-2', + role: 'assistant', + blocks: [{ blockId: 'a2', blockType: 'text', content: 'reply 2' }], + }, + ], + }; + + // WS replay tries to re-deliver only asst-2 + let next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-2' }); + expect(next.current).toBeNull(); // dedup blocked it + expect(next.messages).toHaveLength(4); // all 4 still there + + // A genuinely new message should still work + next = messagesReducer(next, { type: 'MESSAGE_START', messageId: 'asst-3' }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('asst-3'); + }); +}); diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index ad67dd25..ab95d679 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -237,6 +237,10 @@ export function patchToolResult( export function messagesReducer(state: MessagesState, action: MessagesAction): MessagesState { switch (action.type) { case 'MESSAGE_START': { + // Dedup: skip if this message was already restored (e.g. WS replay after RESTORE) + if (state.messages.some((m) => m.messageId === action.messageId)) { + return state; + } const base = state.current ? { ...state, messages: [...state.messages, finishCurrent(state.current)] } : state; @@ -308,7 +312,11 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M } case 'MESSAGE_END': { - if (!state.current) return { ...state }; + if (!state.current) return state; + // Dedup: if this message was already restored, discard the streaming copy + if (state.messages.some((m) => m.messageId === state.current!.messageId)) { + return { ...state, current: null }; + } const finished = finishCurrent(state.current); return { ...state, messages: [...state.messages, finished], current: null }; } @@ -473,9 +481,15 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M merged.splice(insertAfter + 1, 0, opt); } merged.push(notice); - return { ...state, messages: merged }; + const currentStale = + state.current && merged.some((m) => m.messageId === state.current!.messageId); + return { ...state, messages: merged, current: currentStale ? null : state.current }; } - return { ...state, messages: valid }; + // Clear current if the restored set already contains it (prevents + // MESSAGE_END from re-inserting a message that RESTORE already has). + const currentStale = + state.current && valid.some((m) => m.messageId === state.current!.messageId); + return { ...state, messages: valid, current: currentStale ? null : state.current }; } case 'USER_MESSAGE_RECEIVED': { diff --git a/server/__tests__/reconstruct-messages.test.ts b/server/__tests__/reconstruct-messages.test.ts index 93419281..5c27a694 100644 --- a/server/__tests__/reconstruct-messages.test.ts +++ b/server/__tests__/reconstruct-messages.test.ts @@ -290,4 +290,33 @@ describe('replayEventsToMessages — user_message events', () => { blocks: [{ content: 'Hello from empty session' }], }); }); + + it('reuses stored messageId for initial prompt (deterministic across calls)', () => { + const events: StoredEvent[] = [ + evt(1, 'user_message', { messageId: 'umsg-12345-init', text: 'Hello Claude' }), + evt(2, 'message_start', { messageId: 'msg-a1' }), + evt(3, 'block_start', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(4, 'block_delta', { messageId: 'msg-a1', blockId: 'b0', delta: 'Hi!' }), + evt(5, 'block_end', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(6, 'message_end', { messageId: 'msg-a1' }), + ]; + const result1 = replayEventsToMessages(events, 'Hello Claude'); + const result2 = replayEventsToMessages(events, 'Hello Claude'); + expect(result1[0].messageId).toBe('umsg-12345-init'); + expect(result2[0].messageId).toBe('umsg-12345-init'); + // Same ID across calls — no Date.now() instability + expect(result1[0].messageId).toBe(result2[0].messageId); + }); + + it('falls back to stable umsg-initial when no matching event exists', () => { + const events: StoredEvent[] = [ + evt(1, 'message_start', { messageId: 'msg-a1' }), + evt(2, 'block_start', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(3, 'block_delta', { messageId: 'msg-a1', blockId: 'b0', delta: 'Hi!' }), + evt(4, 'block_end', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(5, 'message_end', { messageId: 'msg-a1' }), + ]; + const result = replayEventsToMessages(events, 'Hello Claude'); + expect(result[0].messageId).toBe('umsg-initial'); + }); }); diff --git a/server/chat.ts b/server/chat.ts index 6e012de9..3a91c572 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1543,9 +1543,14 @@ export function replayEventsToMessages( // Inject the initial prompt as the first message. // Priority: initialPrompt param (from session metadata) > legacy out-of-order event if (initialPrompt) { + // Reuse the stored messageId so REST recovery and WS replay agree on IDs + const matchingEvt = events.find( + (e) => e.type === 'user_message' && e.payload.text === initialPrompt, + ); + const messageId = matchingEvt ? (matchingEvt.payload.messageId as string) : 'umsg-initial'; const firstTs = events[0]?.createdAt; messages.push({ - messageId: `umsg-initial-${Date.now()}`, + messageId, role: 'user', timestamp: firstTs, blocks: [{ blockId: 'user-initial', blockType: 'text', content: initialPrompt }], From d5908b3cefac901e92dc758784f239f7104b1c10 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Thu, 14 May 2026 13:45:59 +0100 Subject: [PATCH 32/45] feat(ui): cc-deck attention feed, ATB + Telos visual redesign (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): cc-deck-inspired attention feed, ATB redesign, and Telos sections Homepage "What's Next" attention feed that aggregates Telos focus items, ATB blocked/review tasks, and waiting sessions into a tiered feed — always visible, not just when sessions are active. ATB redesign: 2-line task cards with state-colored left borders, T1 background tint for review/blocked/failed, attention-tier sorting, done-task fade logic (5min→30min opacity decay), sort toggle. Telos redesign: 3-line card layout (summary-first), urgency-as-border- color (red/amber/purple intensity), section grouping (Focus/Active/ Seen/Done with collapsible headers), status-colored icons. Telos: 76732123c7c5, ae3ead922dc8 Co-Authored-By: Claude Opus 4.6 * fix(ui): address Centaur review — attention feed robustness and tier recursion - Remove unused AttentionTier 3 and COLOR_BLUE - Add updatedAt for recency tiebreaker in attention sorting - Fix sortByAttention to consider children status for tier calculation - Fix t1Count to recurse into children Co-Authored-By: Claude Opus 4.6 * fix(ui): address CI failures and review findings for cc-deck PR Remove duplicate variable declarations in TaskBoard.tsx (sortedTasks, showAll, setShowAll already provided by useTaskBoard hook). Remove unused T1_STATUSES, statusMeta, doneOpacity, and formatElapsed from TaskNode.tsx. Fix TaskBoard test mock to include sortedTasks, displayMeta, totalTokenUsage, showAll, setShowAll. Export groupIntoSections from TodoView for testability. Add clarifying comments for intentional design choices (root-only sorting, sort direction differences, SSE-driven display refresh). Co-Authored-By: Claude Opus 4.6 * style(ui): fix Prettier formatting for CI Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/components/AttentionFeed.tsx | 96 ++++++++ frontend/src/components/TaskNode.tsx | 3 + frontend/src/components/TodoCard.tsx | 105 +++++--- .../hooks/__tests__/useAttentionFeed.test.ts | 220 +++++++++++++++++ frontend/src/hooks/useAttentionFeed.ts | 230 ++++++++++++++++++ frontend/src/pages/SessionList.tsx | 3 + frontend/src/pages/TaskBoard.tsx | 62 ++++- frontend/src/pages/TodoView.tsx | 138 +++++++++-- .../src/pages/__tests__/TaskBoard.test.tsx | 5 + frontend/src/styles/global.css | 195 +++++++++++++-- 10 files changed, 984 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/AttentionFeed.tsx create mode 100644 frontend/src/hooks/__tests__/useAttentionFeed.test.ts create mode 100644 frontend/src/hooks/useAttentionFeed.ts diff --git a/frontend/src/components/AttentionFeed.tsx b/frontend/src/components/AttentionFeed.tsx new file mode 100644 index 00000000..aed65ea0 --- /dev/null +++ b/frontend/src/components/AttentionFeed.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAttentionFeed, type AttentionItem } from '../hooks/useAttentionFeed'; +import { selectionChanged } from '../lib/haptics'; + +// ─── Source labels ───────────────────────────────────────────────────────── + +const SOURCE_LABEL: Record = { + telos: 'telos', + atb: 'task', + session: 'session', +}; + +// ─── Card ────────────────────────────────────────────────────────────────── + +function AttentionCard({ + item, + onTap, +}: { + item: AttentionItem; + onTap: (item: AttentionItem) => void; +}) { + return ( + + ); +} + +// ─── Main component ──────────────────────────────────────────────────────── + +export function AttentionFeed() { + const { items, tier1Count, loading } = useAttentionFeed(); + const navigate = useNavigate(); + + const hasUrgent = tier1Count > 0; + const [manualOpen, setManualOpen] = useState(null); + const isOpen = manualOpen ?? true; // always open by default + + const toggleOpen = useCallback(() => { + setManualOpen((prev) => !(prev ?? true)); + }, []); + + const handleTap = useCallback( + (item: AttentionItem) => { + selectionChanged(); + navigate(item.navigateTo); + }, + [navigate], + ); + + // Show section even when empty — gives "all clear" signal + const summaryParts: string[] = []; + if (tier1Count > 0) summaryParts.push(`${tier1Count} needs you`); + const t2Count = items.filter((i) => i.tier === 2).length; + if (t2Count > 0) summaryParts.push(`${t2Count} in focus`); + + return ( +

+ + {isOpen && ( +
+ {!loading && items.length === 0 && ( +
Nothing needs your attention right now.
+ )} + {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/TaskNode.tsx b/frontend/src/components/TaskNode.tsx index d4e62857..ddd45224 100644 --- a/frontend/src/components/TaskNode.tsx +++ b/frontend/src/components/TaskNode.tsx @@ -81,6 +81,9 @@ function contextColorClass(status: TaskStatus): string { return ''; } +// Note: Time-dependent display (fade opacity, elapsed labels) is computed by +// useTaskBoard's displayMeta and refreshed every 60s via setInterval + on each +// SSE task_state event. The component itself is a pure render of that snapshot. export function TaskNode({ task, depth, diff --git a/frontend/src/components/TodoCard.tsx b/frontend/src/components/TodoCard.tsx index 348fd3e3..14258735 100644 --- a/frontend/src/components/TodoCard.tsx +++ b/frontend/src/components/TodoCard.tsx @@ -14,11 +14,38 @@ interface TodoCardProps { onStartSession: (item: TodoItem) => void; } -function urgencyBar(urgency: number): string { - if (urgency >= 0.8) return '\u2593\u2593\u2593'; - if (urgency >= 0.5) return '\u2593\u2593\u2591'; - if (urgency >= 0.2) return '\u2593\u2591\u2591'; - return '\u2591\u2591\u2591'; +// ─── Urgency → color border ──────────────────────────────────────────────── + +function urgencyColor(urgency: number): string { + if (urgency >= 0.8) return '#ff6d6d'; + if (urgency >= 0.5) return '#fbbf24'; + if (urgency >= 0.2) return '#b48cff'; + return 'transparent'; +} + +function urgencyWidth(urgency: number): number { + if (urgency >= 0.8) return 4; + if (urgency >= 0.5) return 3; + if (urgency >= 0.2) return 2; + return 0; +} + +// ─── Status visuals ──────────────────────────────────────────────────────── + +function getStatusIcon(item: TodoItem): string { + if (item.starred) return '\u2605'; // ★ + if (item.status === 'active') return '\u25CF'; // ● + if (item.status === 'acknowledged') return '\u25D0'; // ◐ + if (item.status === 'completed') return '\u2713'; // ✓ + return '\u25CB'; // ○ (snoozed) +} + +function getStatusColor(item: TodoItem): string { + if (item.starred) return '#fbbf24'; + if (item.status === 'active') return '#b48cff'; + if (item.status === 'acknowledged') return '#60a5fa'; + if (item.status === 'completed') return '#4ade80'; + return '#888'; } export function TodoCard({ @@ -102,9 +129,12 @@ export function TodoCard({ const source = item.sources[0]; const ageLabel = item.ageDays === 0 ? 'new' : `${item.ageDays}d`; - const statusIcon = item.status === 'active' ? '\u25CF' : '\u25D0'; const children = item.children ?? []; const hasChildren = children.length > 0; + const icon = getStatusIcon(item); + const color = getStatusColor(item); + const borderClr = urgencyColor(item.urgency); + const borderW = urgencyWidth(item.urgency); return (
0 ? 'todo-card-tree-node--child' : ''}`}> @@ -119,8 +149,18 @@ export function TodoCard({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} + style={{ + borderLeftColor: borderClr, + borderLeftWidth: borderW > 0 ? `${borderW}px` : undefined, + borderLeftStyle: borderW > 0 ? 'solid' : undefined, + }} > -
+ {/* Line 1: icon + summary + star */} +
+ + {icon} + + {item.summary} {hasChildren && (
-
{item.summary}
- {source && ( -
- {source.author} -
- )} -
+ + {/* Line 2: source + meta */} +
+ {source ? ( + {sourceIcon(source.type)} + ) : ( + + + )} + {source?.author && ( + <> + {source.author} + {' \u00B7 '} + + )} + {ageLabel} + {' \u00B7 '} + {item.profile} + {hasChildren && ( + <> + {' \u00B7 '} + + {item.completedChildCount ?? 0}/{item.childCount ?? children.length} + + + )} +
+ + {/* Line 3: actions */} +
+ ); +} + +// ─── Main view ───────────────────────────────────────────────────────────── + export function TodoView() { const navigate = useNavigate(); const location = useLocation(); @@ -84,6 +164,11 @@ export function TodoView() { const [creating, setCreating] = useState<{ parentId?: string } | null>(null); const setPendingSession = useMitzoStore((s) => s.setPendingSession); const scrollRef = useRef(null); + const [collapsedSections, setCollapsedSections] = useState>({ + done: true, + }); + + const sections = useMemo(() => groupIntoSections(items), [items]); // Restore scroll position when returning from detail view useEffect(() => { @@ -97,6 +182,10 @@ export function TodoView() { return scrollRef.current?.scrollTop ?? 0; }, []); + function toggleSection(key: string) { + setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] })); + } + function handleStartSession(item: TodoItem) { setPendingSession({ prompt: buildPrompt(item), @@ -175,24 +264,35 @@ export function TodoView() { /> )} -
- {items.length > 0 && Tap to start working. Swipe right = seen, left = done.} -
- -
- {items.map((item) => ( - - ))} -
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] ?? section.defaultCollapsed; + return ( +
+ toggleSection(section.key)} + /> + {!isCollapsed && ( +
+ {section.items.map((item) => ( + + ))} +
+ )} +
+ ); + })}
); diff --git a/frontend/src/pages/__tests__/TaskBoard.test.tsx b/frontend/src/pages/__tests__/TaskBoard.test.tsx index f4c80578..2ab455bc 100644 --- a/frontend/src/pages/__tests__/TaskBoard.test.tsx +++ b/frontend/src/pages/__tests__/TaskBoard.test.tsx @@ -60,6 +60,11 @@ vi.mock('../../hooks/useTaskBoard', () => ({ useTaskBoard: () => ({ loading: mockLoading, tasks: mockTasks, + sortedTasks: mockTasks, + displayMeta: new Map(), + totalTokenUsage: 0, + showAll: false, + setShowAll: vi.fn(), loopStatus: { state: 'idle', goalId: null, diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 326ebf63..ca6ad293 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -755,6 +755,80 @@ textarea:focus { margin-top: 2px; } +/* ─── Attention Feed ────────────────────────────────────────────────────── */ + +.attention-section { + margin-bottom: var(--space-4); +} + +.attention-cards { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); +} + +.attention-card { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--card-accent, var(--border)); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--text); + font-family: inherit; + font-size: var(--text-sm); + width: 100%; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} + +.attention-card:active { + background: var(--hover); +} + +.attention-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; +} + +.attention-card-content { + flex: 1; + min-width: 0; +} + +.attention-card-title { + font-size: var(--text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.attention-card-meta { + font-size: var(--text-xs); + color: var(--text-dim); + margin-top: 2px; +} + +.attention-card-source { + font-weight: 600; + text-transform: uppercase; + font-size: var(--text-2xs); + letter-spacing: 0.03em; +} + +.attention-empty { + font-size: var(--text-xs); + color: var(--text-dim); + padding: var(--space-3) 0; + text-align: center; +} + .service-status { display: flex; gap: var(--space-4); @@ -4324,24 +4398,42 @@ textarea:focus { padding: 12px 14px; cursor: pointer; touch-action: pan-y; + display: flex; + flex-direction: column; + gap: 4px; } -.todo-card-header { +/* ─── 3-line card layout ──────────────────────────────────────────── */ + +.todo-card-line1 { display: flex; - align-items: center; + align-items: flex-start; gap: var(--space-2); - margin-bottom: var(--space-1); - font-size: var(--text-xs); } -.todo-card-status { - color: var(--accent); +.todo-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; } -.todo-card-urgency { - font-family: monospace; - letter-spacing: 1px; +.todo-card-summary { + flex: 1; + font-size: var(--text-sm); + line-height: 1.35; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.todo-card-line2 { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); color: var(--text-dim); + padding-left: calc(var(--text-base) + var(--space-2)); } .todo-card-source { @@ -4355,22 +4447,72 @@ textarea:focus { .todo-card-age { color: var(--text-dim); - margin-left: auto; } -.todo-card-summary { - font-size: var(--text-sm); - line-height: 1.35; +.todo-card-profile { + color: var(--text-dim); } -.todo-card-meta { - margin-top: var(--space-1); - font-size: var(--text-xxs); +.todo-card-author { color: var(--text-dim); } -.todo-card-author { +.todo-card-line3 { + display: flex; + gap: var(--space-2); + padding-left: calc(var(--text-base) + var(--space-2)); + margin-top: 2px; +} + +/* ─── Section headers ─────────────────────────────────────────────── */ + +.todo-section { + margin-bottom: var(--space-3); +} + +.todo-section-header { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) 0; + background: transparent; + border: none; color: var(--text-dim); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.todo-section-label { + flex-shrink: 0; +} + +.todo-section-count { + flex-shrink: 0; + font-weight: 400; + opacity: 0.7; +} + +.todo-section-line { + flex: 1; + height: 1px; + background: var(--border); +} + +.todo-section-chevron { + display: inline-block; + font-size: var(--text-base); + transition: transform 0.2s; + transform: rotate(0deg); + flex-shrink: 0; +} + +.todo-section-chevron--open { + transform: rotate(90deg); } /* --- Hierarchical todo tree --- */ @@ -4419,7 +4561,6 @@ textarea:focus { padding: 2px 8px; border-radius: 10px; cursor: pointer; - margin-top: var(--space-1); opacity: 0.4; transition: opacity 0.15s; } @@ -4430,12 +4571,6 @@ textarea:focus { opacity: 1; } -.todo-card-actions { - display: flex; - gap: var(--space-2); - margin-top: var(--space-1); -} - .todo-card-session-btn { background: none; border: 1px solid var(--accent, #6366f1); @@ -5038,11 +5173,17 @@ textarea:focus { .task-node-body { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } .task-node-title { font-size: var(--text-md); line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .task-node-title--done { @@ -5067,6 +5208,12 @@ textarea:focus { color: var(--task-failed); } +.task-node-meta { + font-size: var(--text-xs); + color: var(--text-dim); + line-height: 1.3; +} + .task-node-actions { display: flex; gap: var(--space-1); From 8ac65cd024ef2dbb02df2bb212fa25e50bcffa40 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Sat, 16 May 2026 09:24:12 +0100 Subject: [PATCH 33/45] fix(ui): markdown preview card promotion broken in ReactMarkdown v10 (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): markdown preview card promotion broken in ReactMarkdown v10 Two compounding bugs prevented .md file paths from rendering as expandable preview cards: 1. ReactMarkdown v10 sanitizes unknown URL schemes — file-path:// was stripped to "", so the `a` handler never matched. Fix: pass urlTransform that preserves all URLs. 2. The `p` handler checked `data-file-path` on children, but in v10 children are unrendered component instances (the `a` handler hasn't run yet). Fix: check `href` prop directly, which ReactMarkdown does pass before rendering. Co-Authored-By: Claude Opus 4.6 * fix(ui): address PR review — targeted urlTransform + missing tests - Use defaultUrlTransform for non-file-path URLs instead of identity passthrough, preserving sanitization of javascript:/data: schemes - Add .mdx promotion test to cover both branches of the regex - Add regular URL fallthrough test for the p handler Co-Authored-By: Claude Opus 4.6 * fix(ui): address PR #323 review — restore URL sanitization + test urlTransform - Add defaultUrlTransform to the react-markdown mock so the named import resolves correctly at test time - Capture urlTransform prop in the mock to enable direct assertions - Add test verifying file-path:// URLs are preserved while all other URLs (including dangerous schemes like javascript:) are delegated to defaultUrlTransform for sanitization Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/components/MessageBubble.tsx | 17 ++++-- .../__tests__/MessageBubble.test.ts | 56 +++++++++++++++++-- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index c2edba60..8537ff97 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import ReactMarkdown from 'react-markdown'; +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useNavigate, useLocation } from 'react-router-dom'; import type { FinishedMessage } from '../types/chat'; @@ -106,6 +106,7 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
(url.startsWith(FILE_SCHEME) ? url : defaultUrlTransform(url))} components={{ table: ({ children, ...props }) => (
@@ -123,15 +124,19 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: }, // When a paragraph contains a single file-path link to a .md/.mdx // file, promote it to an inline preview card instead of a plain link. - // The `a` handler passes the resolved path via data-file-path; this - // handler checks the extension and decides whether to promote. + // In ReactMarkdown v10, children are unrendered component instances — + // the `a` handler hasn't run yet — so we check `href` (the prop + // ReactMarkdown passes) rather than rendered DOM attributes. p: ({ children }) => { const childArray = React.Children.toArray(children); if (childArray.length === 1 && React.isValidElement(childArray[0])) { const el = childArray[0] as React.ReactElement>; - const filePath = el.props?.['data-file-path'] as string | undefined; - if (filePath && /\.mdx?$/i.test(filePath)) { - return ; + const href = el.props?.href as string | undefined; + if (href?.startsWith(FILE_SCHEME)) { + const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); + if (/\.mdx?$/i.test(filePath)) { + return ; + } } } return

{children}

; diff --git a/frontend/src/components/__tests__/MessageBubble.test.ts b/frontend/src/components/__tests__/MessageBubble.test.ts index d2e36a9f..24b4058b 100644 --- a/frontend/src/components/__tests__/MessageBubble.test.ts +++ b/frontend/src/components/__tests__/MessageBubble.test.ts @@ -4,18 +4,24 @@ import { describe, it, expect, vi } from 'vitest'; // eslint-disable-next-line @typescript-eslint/no-explicit-any let capturedComponents: Record | undefined; let capturedContent: string | undefined; + +let capturedUrlTransform: ((url: string) => string) | undefined; vi.mock('react-markdown', () => ({ default: ({ children, components, + urlTransform, }: { children: string; components?: Record; + urlTransform?: (url: string) => string; }) => { capturedComponents = components; capturedContent = children; + capturedUrlTransform = urlTransform; return children; }, + defaultUrlTransform: (url: string) => `sanitized:${url}`, })); vi.mock('remark-gfm', () => ({ default: () => {} })); @@ -230,8 +236,9 @@ describe('TextBubble markdown preview card promotion', () => { it('promotes a standalone .md file-path link to MarkdownPreviewCard', () => { renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); const p = capturedComponents!.p; - // Simulate what the a handler returns for a .md file-path link - const link = createElement('a', { 'data-file-path': '/tmp/notes.md' }, '/tmp/notes.md'); + // Simulate what ReactMarkdown v10 passes: an unrendered component with href prop + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const link = createElement('a', { href: fileHref }, '/tmp/notes.md'); const result = p({ children: link }); // Should return a MarkdownPreviewCard, not a

@@ -242,7 +249,8 @@ describe('TextBubble markdown preview card promotion', () => { it('does not promote non-.md file-path links', () => { renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); const p = capturedComponents!.p; - const link = createElement('a', { 'data-file-path': '/tmp/data.json' }, '/tmp/data.json'); + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/data.json')}`; + const link = createElement('a', { href: fileHref }, '/tmp/data.json'); const result = p({ children: link }); expect(result.type).toBe('p'); @@ -251,12 +259,33 @@ describe('TextBubble markdown preview card promotion', () => { it('does not promote .md links when paragraph has other content', () => { renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); const p = capturedComponents!.p; - const link = createElement('a', { 'data-file-path': '/tmp/notes.md' }, '/tmp/notes.md'); + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const link = createElement('a', { href: fileHref }, '/tmp/notes.md'); const result = p({ children: ['See ', link, ' for details'] }); expect(result.type).toBe('p'); }); + it('promotes a standalone .mdx file-path link to MarkdownPreviewCard', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/design.mdx')}`; + const link = createElement('a', { href: fileHref }, '/tmp/design.mdx'); + + const result = p({ children: link }); + expect(result.type).not.toBe('p'); + expect(result.props.filePath).toBe('/tmp/design.mdx'); + }); + + it('does not promote regular URL links in a solo paragraph', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const link = createElement('a', { href: 'https://example.com' }, 'Example'); + + const result = p({ children: link }); + expect(result.type).toBe('p'); + }); + it('a handler sets data-file-path on file-path links', () => { renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); const anchor = capturedComponents!.a; @@ -266,6 +295,25 @@ describe('TextBubble markdown preview card promotion', () => { }); }); +describe('TextBubble urlTransform', () => { + it('preserves file-path:// URLs and delegates others to defaultUrlTransform', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + expect(capturedUrlTransform).toBeDefined(); + + // file-path:// URLs are preserved as-is (not passed through defaultUrlTransform) + const fileUrl = 'file-path://%2Ftmp%2Fnotes.md'; + expect(capturedUrlTransform!(fileUrl)).toBe(fileUrl); + + // Non-file-path URLs are delegated to defaultUrlTransform (mock prefixes with "sanitized:") + const httpUrl = 'https://example.com'; + expect(capturedUrlTransform!(httpUrl)).toBe(`sanitized:${httpUrl}`); + + // Dangerous schemes should also go through defaultUrlTransform, not be preserved + const jsUrl = 'javascript:alert(1)'; + expect(capturedUrlTransform!(jsUrl)).toBe(`sanitized:${jsUrl}`); + }); +}); + describe('MessageBubble legacy adapter forwards timestamps', () => { it('forwards timestamp to UserBubble for user messages', () => { const ts = Date.now(); From 8190252f44df61f620b1d519a485df39b15b4190 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Sat, 16 May 2026 10:53:08 +0100 Subject: [PATCH 34/45] fix(client): recover SSE EventSource on browser visibilitychange (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari kills EventSource connections when the tab is backgrounded without firing an error event, so the browser's native auto-reconnect never triggers. The Capacitor lifecycle hook already called ensureConnected() on resume, but browser clients had no equivalent recovery path — leaving health events (and thus the voice mic button) permanently dead after a single background cycle. Add a visibilitychange listener in the EventBus singleton that calls ensureConnected() when the page becomes visible again. Co-authored-by: Claude Opus 4.6 --- .../lib/__tests__/event-bus-singleton.test.ts | 57 +++++++++++++++++++ frontend/src/lib/event-bus-singleton.ts | 11 ++++ 2 files changed, 68 insertions(+) create mode 100644 frontend/src/lib/__tests__/event-bus-singleton.test.ts diff --git a/frontend/src/lib/__tests__/event-bus-singleton.test.ts b/frontend/src/lib/__tests__/event-bus-singleton.test.ts new file mode 100644 index 00000000..598a86a8 --- /dev/null +++ b/frontend/src/lib/__tests__/event-bus-singleton.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// Tests for the global SSE EventBus singleton's visibilitychange recovery. + +import { describe, it, expect, vi } from 'vitest'; + +// Mock EventSource (jsdom doesn't provide it) +class MockEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + readyState = MockEventSource.CONNECTING; + onopen: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + close = vi.fn(); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); +} + +// Must be set before module loads — static imports are hoisted above beforeAll +global.EventSource = MockEventSource as unknown as typeof EventSource; + +// Dynamic import so the module-level side effects run after EventSource is defined +const { eventBus } = await import('../event-bus-singleton'); + +describe('event-bus-singleton visibilitychange recovery', () => { + it('calls ensureConnected when page becomes visible', () => { + const ensureConnectedSpy = vi.spyOn(eventBus, 'ensureConnected'); + + // Simulate page becoming visible + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(ensureConnectedSpy).toHaveBeenCalled(); + ensureConnectedSpy.mockRestore(); + }); + + it('does not call ensureConnected when page becomes hidden', () => { + const ensureConnectedSpy = vi.spyOn(eventBus, 'ensureConnected'); + + // Simulate page becoming hidden + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(ensureConnectedSpy).not.toHaveBeenCalled(); + ensureConnectedSpy.mockRestore(); + }); +}); diff --git a/frontend/src/lib/event-bus-singleton.ts b/frontend/src/lib/event-bus-singleton.ts index a3945129..90379ba4 100644 --- a/frontend/src/lib/event-bus-singleton.ts +++ b/frontend/src/lib/event-bus-singleton.ts @@ -3,6 +3,7 @@ * * Lazily connected on first import. Hooks subscribe via eventBus.on(). * On iOS resume, ensureConnected() is called to recover from CLOSED state. + * On page visibility change, ensureConnected() reconnects if connection died. */ import { EventBus } from '@mitzo/client'; @@ -13,3 +14,13 @@ export const eventBus = new EventBus(); // Connect immediately — EventSource auto-reconnects natively const sseUrl = `${getApiBaseUrl()}/api/events`; eventBus.connect(sseUrl); + +// Recover from dead SSE connections when page becomes visible again +// (e.g., iOS Safari backgrounding kills EventSource without firing error) +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + eventBus.ensureConnected(); + } + }); +} From cbdccd84b567875034f1a6dcc683e8dcc8f1feb6 Mon Sep 17 00:00:00 2001 From: Dimitri Saridakis Date: Sat, 16 May 2026 11:52:29 +0100 Subject: [PATCH 35/45] fix(ios): forward APNs device token to Capacitor (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(watch): trust Tailscale certs + enable shared Keychain on iOS - Add TailscaleTrustDelegate that accepts TLS certs for *.ts.net, *.tail, and localhost hosts (matching Capacitor allowNavigation). - Replace URLSession.shared with tailscaleURLSession in AuthManager, MitzoAPIClient, and MitzoWSClient. Fixes "Login failed" on watch caused by URLSession rejecting the self-signed Tailscale cert. - Add keychain-access-groups to iOS App.entitlements so both iPhone and watch apps share the same Keychain access group. Made-with: Cursor * fix(ios): forward APNs device token to Capacitor PushNotifications plugin AppDelegate was missing didRegisterForRemoteNotificationsWithDeviceToken and didFailToRegisterForRemoteNotificationsWithError — iOS delivered the token but it was never forwarded to Capacitor via NotificationCenter, so the 'registration' event never fired and no token reached the server. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/ios/App/App/AppDelegate.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/ios/App/App/AppDelegate.swift b/frontend/ios/App/App/AppDelegate.swift index c3cd83b5..24ece850 100644 --- a/frontend/ios/App/App/AppDelegate.swift +++ b/frontend/ios/App/App/AppDelegate.swift @@ -46,4 +46,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + } From f1f26549b495f92f3af3f84dd2b38a9b0a2cd183 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 12:03:35 +0100 Subject: [PATCH 36/45] feat(watch): relay-only networking + native dictation input Watch app now routes all traffic through iPhone via WatchConnectivity relay instead of attempting direct connections to Tailscale IPs (blocked by NECP policy on watchOS). Adds list_sessions and get_messages relay message types so the watch can load sessions and history through the phone. Replaces Yapper-based voice input with watchOS native text input (dictation + keyboard). Adds REST fallback for service health on iOS (SSE fails with self-signed certs on WebKit). Co-Authored-By: Claude Opus 4.6 --- .../ios/App/App.xcodeproj/project.pbxproj | 25 ++- .../ios/App/App/WatchRelayCoordinator.swift | 66 ++++++++ frontend/ios/App/WatchAuthBridge.swift | 45 +++++ frontend/ios/App/WatchRelayCoordinator.swift | 64 +++++++ .../MitzoShared/Networking/WatchRelay.swift | 99 ++++++++++- .../ios/MitzoWatch/Services/AppState.swift | 158 ++++-------------- .../MitzoWatch/Services/ChatViewModel.swift | 27 ++- frontend/ios/MitzoWatch/Views/ChatView.swift | 126 +++++++++----- .../ios/MitzoWatch/Views/ComposeSheet.swift | 30 ++++ .../MitzoWatch/Views/SessionListView.swift | 2 +- .../ios/MitzoWatch/Views/VoiceInputBar.swift | 86 ++++------ frontend/src/hooks/useServiceHealth.ts | 44 ++++- server/app.ts | 5 + 13 files changed, 526 insertions(+), 251 deletions(-) create mode 100644 frontend/ios/App/App/WatchRelayCoordinator.swift create mode 100644 frontend/ios/App/WatchAuthBridge.swift create mode 100644 frontend/ios/App/WatchRelayCoordinator.swift create mode 100644 frontend/ios/MitzoWatch/Views/ComposeSheet.swift diff --git a/frontend/ios/App/App.xcodeproj/project.pbxproj b/frontend/ios/App/App.xcodeproj/project.pbxproj index 0f88a83f..121ef7b1 100644 --- a/frontend/ios/App/App.xcodeproj/project.pbxproj +++ b/frontend/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1F97793D2FB86A7A00B87829 /* WatchAuthBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */; }; + 1F97793E2FB86A8000B87829 /* WatchRelayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */; }; 2C807BBEF27900A4B4C713F3 /* VoiceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E438A1E5CA844963819792C4 /* VoiceService.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 30271F81931105049A8D0BCA /* MitzoShared in Frameworks */ = {isa = PBXBuildFile; productRef = 2994CBB574BE2544F671F9BF /* MitzoShared */; }; @@ -20,19 +22,20 @@ 51C82AC7CC9D0CD9BB2B8A2D /* MitzoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729F6EAA7E6281CC8E027E58 /* MitzoWatchApp.swift */; }; 87D5F3E4B2B79830BDC6F3CA /* VoiceInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */; }; 91D36789BA83D98F79CA5EA1 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */; }; - 9A8EE45055EB9BF5AB711065 /* PermissionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB84607485C9AAA4D695F477 /* PermissionBanner.swift */; }; B87D83BB43FD8D7A331D5C16 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60979EA195F633EE664E716 /* LoginView.swift */; }; BDF94F63DBEDEAABE3DBA4B8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6B3AF931A2A562E89BCCA0 /* ChatView.swift */; }; E7C8020332FACEAD32C32377 /* MitzoShared in Frameworks */ = {isa = PBXBuildFile; productRef = A997DA8238CC43203EE0AD73 /* MitzoShared */; }; EB52F01B4D4DE944429DFFB7 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3543D568BAEA16F3464C2D /* SessionListView.swift */; }; EBDB8718D00B3CAC47EFD6EC /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB2544C9BF33F98A575F8F9F /* AppState.swift */; }; + F1A2B3C4D5E6F70800000001 /* ComposeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRelayCoordinator.swift; sourceTree = ""; }; + 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAuthBridge.swift; sourceTree = ""; }; 264FB5577C8B9098BE816A36 /* MitzoWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MitzoWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; - 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -50,6 +53,7 @@ AB84607485C9AAA4D695F477 /* PermissionBanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PermissionBanner.swift; sourceTree = ""; }; CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceInputBar.swift; sourceTree = ""; }; + F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeSheet.swift; sourceTree = ""; }; E438A1E5CA844963819792C4 /* VoiceService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceService.swift; sourceTree = ""; }; FA1E96663E02190E82402696 /* MitzoWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MitzoWatch.entitlements; sourceTree = ""; }; FB2544C9BF33F98A575F8F9F /* AppState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; @@ -77,7 +81,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( @@ -104,6 +107,8 @@ A1B2C3D40000000000000001 /* App.entitlements */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */, + 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -121,7 +126,6 @@ CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */, E438A1E5CA844963819792C4 /* VoiceService.swift */, ); - name = Services; path = Services; sourceTree = ""; }; @@ -145,9 +149,9 @@ AB84607485C9AAA4D695F477 /* PermissionBanner.swift */, CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */, FF6B3AF931A2A562E89BCCA0 /* ChatView.swift */, + F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */, 9A3543D568BAEA16F3464C2D /* SessionListView.swift */, ); - name = Views; path = Views; sourceTree = ""; }; @@ -233,7 +237,7 @@ mainGroup = 504EC2FB1FED79650016851F; packageReferences = ( D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, - F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */, + F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */, ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; @@ -273,6 +277,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1F97793D2FB86A7A00B87829 /* WatchAuthBridge.swift in Sources */, + 1F97793E2FB86A8000B87829 /* WatchRelayCoordinator.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -289,6 +295,7 @@ 9A8EE45055EB9BF5AB711065 /* PermissionBanner.swift in Sources */, 87D5F3E4B2B79830BDC6F3CA /* VoiceInputBar.swift in Sources */, BDF94F63DBEDEAABE3DBA4B8 /* ChatView.swift in Sources */, + F1A2B3C4D5E6F70800000001 /* ComposeSheet.swift in Sources */, EB52F01B4D4DE944429DFFB7 /* SessionListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -565,7 +572,7 @@ isa = XCLocalSwiftPackageReference; relativePath = "CapApp-SPM"; }; - F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */ = { + F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../MitzoShared; }; @@ -574,7 +581,7 @@ /* Begin XCSwiftPackageProductDependency section */ 2994CBB574BE2544F671F9BF /* MitzoShared */ = { isa = XCSwiftPackageProductDependency; - package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */; + package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */; productName = MitzoShared; }; 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { @@ -584,7 +591,7 @@ }; A997DA8238CC43203EE0AD73 /* MitzoShared */ = { isa = XCSwiftPackageProductDependency; - package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */; + package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */; productName = MitzoShared; }; /* End XCSwiftPackageProductDependency section */ diff --git a/frontend/ios/App/App/WatchRelayCoordinator.swift b/frontend/ios/App/App/WatchRelayCoordinator.swift new file mode 100644 index 00000000..4127e537 --- /dev/null +++ b/frontend/ios/App/App/WatchRelayCoordinator.swift @@ -0,0 +1,66 @@ +// Coordinates native WS connection + WatchRelay for Apple Watch communication. +// The Capacitor web layer has its own WS; this is a second native connection +// used exclusively by WatchRelayHost to bridge watch ↔ server messages. + +import UIKit +import MitzoShared + +final class WatchRelayCoordinator: @unchecked Sendable { + private let authManager = AuthManager() + private let watchRelay: WatchRelayHost + private var wsClient: MitzoWSClient? + private let lock = NSLock() + + private var serverURL: URL { + let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") + return URL(string: stored ?? "https://dimakis-mac.tail:3100")! + } + + init() { + watchRelay = WatchRelayHost(authManager: authManager) + } + + func start() { + Task { await connect() } + } + + func reconnect() { + Task { await connect() } + } + + func suspend() { + Task { + let client: MitzoWSClient? = lock.withLock { wsClient } + if let client { + let sessions = await client.getSuspendSessions() + if !sessions.isEmpty { + try? await client.suspend(sessions: sessions) + } + } + } + } + + private func connect() async { + guard let token = try? await authManager.getToken() else { return } + + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/ws/chat" + components.queryItems = [URLQueryItem(name: "token", value: token)] + + guard let wsURL = components.url else { return } + + let client = MitzoWSClient(url: wsURL) + lock.withLock { wsClient = client } + + let api = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + watchRelay.activate(wsClient: client, apiClient: api) + + let relay = watchRelay + await client.connect { event in + if case .message(let msg) = event { + relay.forwardToWatch(msg) + } + } + } +} diff --git a/frontend/ios/App/WatchAuthBridge.swift b/frontend/ios/App/WatchAuthBridge.swift new file mode 100644 index 00000000..199bf537 --- /dev/null +++ b/frontend/ios/App/WatchAuthBridge.swift @@ -0,0 +1,45 @@ +// Capacitor plugin that bridges web auth tokens into the native shared Keychain. +// When the web app logs in, it calls WatchAuthBridge.saveToken() so the +// watch can read the JWT from the shared Keychain access group. + +import Capacitor +import MitzoShared + +@objc(WatchAuthBridge) +public class WatchAuthBridge: CAPPlugin, CAPBridgedPlugin { + public let identifier = "WatchAuthBridge" + public let jsName = "WatchAuthBridge" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "saveToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearToken", returnType: CAPPluginReturnPromise), + ] + + private let authManager = AuthManager() + + @objc func saveToken(_ call: CAPPluginCall) { + guard let token = call.getString("token") else { + call.reject("Missing token") + return + } + + Task { + do { + try await authManager.saveToken(token) + call.resolve() + } catch { + call.reject("Failed to save token: \(error.localizedDescription)") + } + } + } + + @objc func clearToken(_ call: CAPPluginCall) { + Task { + do { + try await authManager.clearToken() + call.resolve() + } catch { + call.reject("Failed to clear token: \(error.localizedDescription)") + } + } + } +} diff --git a/frontend/ios/App/WatchRelayCoordinator.swift b/frontend/ios/App/WatchRelayCoordinator.swift new file mode 100644 index 00000000..dc13b2a8 --- /dev/null +++ b/frontend/ios/App/WatchRelayCoordinator.swift @@ -0,0 +1,64 @@ +// Coordinates native WS connection + WatchRelay for Apple Watch communication. +// The Capacitor web layer has its own WS; this is a second native connection +// used exclusively by WatchRelayHost to bridge watch ↔ server messages. + +import UIKit +import MitzoShared + +final class WatchRelayCoordinator: @unchecked Sendable { + private let authManager = AuthManager() + private let watchRelay: WatchRelayHost + private var wsClient: MitzoWSClient? + private let lock = NSLock() + + private var serverURL: URL { + let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") + return URL(string: stored ?? "https://dimakis-mac.tail:3100")! + } + + init() { + watchRelay = WatchRelayHost(authManager: authManager) + } + + func start() { + Task { await connect() } + } + + func reconnect() { + Task { await connect() } + } + + func suspend() { + Task { + let client: MitzoWSClient? = lock.withLock { wsClient } + if let client { + let sessions = await client.getSuspendSessions() + if !sessions.isEmpty { + try? await client.suspend(sessions: sessions) + } + } + } + } + + private func connect() async { + guard let token = try? await authManager.getToken() else { return } + + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/ws/chat" + components.queryItems = [URLQueryItem(name: "token", value: token)] + + guard let wsURL = components.url else { return } + + let client = MitzoWSClient(url: wsURL) + lock.withLock { wsClient = client } + watchRelay.activate(wsClient: client) + + let relay = watchRelay + await client.connect { event in + if case .message(let msg) = event { + relay.forwardToWatch(msg) + } + } + } +} diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index a94102dc..01835f5a 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -26,8 +26,9 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { super.init() } - public func activate(wsClient: MitzoWSClient) { + public func activate(wsClient: MitzoWSClient, apiClient: MitzoAPIClient? = nil) { state.setWSClient(wsClient) + state.setAPIClient(apiClient) guard WCSession.isSupported() else { return } WCSession.default.delegate = self @@ -70,6 +71,41 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { try await state.getWSClient()?.send(clientMsg) reply.value(["ok": true]) + case "get_messages": + let sessionId = message["sessionId"] as? String ?? "" + if let apiClient = state.getAPIClient() { + do { + let messages: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) + let data = try JSONEncoder().encode(messages) + if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + reply.value(["_payload": arr]) + } else { + reply.value(["error": "encoding_failed"]) + } + } catch { + reply.value(["error": error.localizedDescription]) + } + } else { + reply.value(["error": "no_api_client"]) + } + + case "list_sessions": + if let apiClient = state.getAPIClient() { + do { + let response = try await apiClient.getSessions() + let data = try JSONEncoder().encode(response) + if let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + reply.value(["_payload": dict]) + } else { + reply.value(["error": "encoding_failed"]) + } + } catch { + reply.value(["error": error.localizedDescription]) + } + } else { + reply.value(["error": "no_api_client"]) + } + case "auth_token": if let token = try? await authManager.getToken() { reply.value(["token": token]) @@ -180,6 +216,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { /// arrive on a background serial queue, so we need synchronization. private final class WatchRelayHostState: Sendable { private nonisolated(unsafe) var _wsClient: MitzoWSClient? + private nonisolated(unsafe) var _apiClient: MitzoAPIClient? private let lock = NSLock() func setWSClient(_ client: MitzoWSClient) { @@ -193,6 +230,18 @@ private final class WatchRelayHostState: Sendable { defer { lock.unlock() } return _wsClient } + + func setAPIClient(_ client: MitzoAPIClient?) { + lock.lock() + _apiClient = client + lock.unlock() + } + + func getAPIClient() -> MitzoAPIClient? { + lock.lock() + defer { lock.unlock() } + return _apiClient + } } enum RelayError: Error { @@ -245,6 +294,52 @@ public final class WatchRelayClient: NSObject, WCSessionDelegate, Sendable { } } + /// Request messages for a session from phone (phone calls server REST API) + public func requestMessages(sessionId: String) async throws -> [FinishedMessage] { + let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in + let cont = UnsafeSendable(continuation) + WCSession.default.sendMessage( + ["_relay": "get_messages", "sessionId": sessionId], + replyHandler: { @Sendable reply in cont.value.resume(returning: UnsafeSendable(reply).value) }, + errorHandler: { @Sendable error in cont.value.resume(throwing: error) } + ) + } + + if let errorMsg = reply["error"] as? String { + throw WatchRelayError.relayError(errorMsg) + } + + guard let payload = reply["_payload"] as? [[String: Any]] else { + throw WatchRelayError.invalidResponse + } + + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode([FinishedMessage].self, from: data) + } + + /// Request session list from phone (phone calls server REST API) + public func requestSessions() async throws -> SessionsResponse { + let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in + let cont = UnsafeSendable(continuation) + WCSession.default.sendMessage( + ["_relay": "list_sessions"], + replyHandler: { @Sendable reply in cont.value.resume(returning: UnsafeSendable(reply).value) }, + errorHandler: { @Sendable error in cont.value.resume(throwing: error) } + ) + } + + if let errorMsg = reply["error"] as? String { + throw WatchRelayError.relayError(errorMsg) + } + + guard let payload = reply["_payload"] as? [String: Any] else { + throw WatchRelayError.invalidResponse + } + + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode(SessionsResponse.self, from: data) + } + /// Request auth token from phone public func requestAuthToken() async throws -> String { let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in @@ -301,5 +396,7 @@ private final class WatchRelayClientState: Sendable { enum WatchRelayError: Error { case noToken case notReachable + case invalidResponse + case relayError(String) } #endif diff --git a/frontend/ios/MitzoWatch/Services/AppState.swift b/frontend/ios/MitzoWatch/Services/AppState.swift index ca394106..f8e6b16d 100644 --- a/frontend/ios/MitzoWatch/Services/AppState.swift +++ b/frontend/ios/MitzoWatch/Services/AppState.swift @@ -18,18 +18,13 @@ final class AppState: ObservableObject { @Published var error: String? private let authManager = AuthManager() - private var wsClient: MitzoWSClient? - private var apiClient: MitzoAPIClient? private var activeChatVM: ChatViewModel? private let relayClient = WatchRelayClient() - private var reconnectAttempts = 0 - private var isReconnecting = false - private static let maxReconnectDelay: UInt64 = 30_000_000_000 // 30s - // Configurable via UserDefaults; defaults to Tailscale hostname + // Server URL used only for login (brief HTTP POST that sometimes works) var serverURL: URL { let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") - return URL(string: stored ?? "https://mitzo.tail:3100")! + return URL(string: stored ?? "http://100.91.50.57:3101")! } init() { @@ -76,105 +71,51 @@ final class AppState: ObservableObject { } } - // MARK: - Connection (waterfall: direct → relay) + // MARK: - Connection (relay-only — direct to Tailscale IPs is blocked by NECP) func connect() async { - let directSuccess = await connectDirect() - if directSuccess { - reconnectAttempts = 0 - return + // watchOS cannot reach Tailscale IPs due to NECP policy on the + // iPhone's VPN extension. All traffic goes through the iPhone + // via WatchConnectivity relay. + + // WCSession activation is async — give it a moment if not ready yet + if !relayClient.isPhoneReachable { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2s } if relayClient.isPhoneReachable { connectionMode = .relay connectionState = .connected(connectionId: "relay") - reconnectAttempts = 0 await loadSessionsViaRelay() } else { connectionMode = .none - error = "Cannot reach server or iPhone" + connectionState = .disconnected + error = "iPhone not reachable — open Mitzo on your phone" + // Don't reconnect-loop. WCSession reachability changes will + // trigger a retry via the session delegate (future enhancement). } } - private func connectDirect() async -> Bool { - guard let token = try? await authManager.getToken() else { return false } - - var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! - components.scheme = components.scheme == "https" ? "wss" : "ws" - components.path = "/ws/chat" - components.queryItems = [URLQueryItem(name: "token", value: token)] - - guard let wsURL = components.url else { return false } - - let client = MitzoWSClient(url: wsURL) - wsClient = client - - apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) - - let connected = await withCheckedContinuation { continuation in - var resolved = false - - Task { - await client.connect { [weak self] event in - Task { @MainActor in - self?.handleWSEvent(event) - - if !resolved { - if case .stateChanged(.connected) = event { - resolved = true - continuation.resume(returning: true) - } - } - } - } - } + // MARK: - Sessions & Messages - Task { - try? await Task.sleep(nanoseconds: 5_000_000_000) - if !resolved { - resolved = true - continuation.resume(returning: false) - } - } - } - - if connected { - connectionMode = .direct - await loadSessions() - return true - } else { - await client.disconnect() - wsClient = nil - return false - } + func loadMessages(sessionId: String) async throws -> [FinishedMessage] { + return try await relayClient.requestMessages(sessionId: sessionId) } - func suspend() async { - guard let client = wsClient else { return } - let sessions = await client.getSuspendSessions() - if !sessions.isEmpty { - try? await client.suspend(sessions: sessions) - } + func refreshSessions() async { + await loadSessionsViaRelay() } - // MARK: - Sessions - - func loadSessions() async { + private func loadSessionsViaRelay() async { do { - let response = try await apiClient?.getSessions() ?? SessionsResponse(sessions: [], hasMore: false) + let response = try await relayClient.requestSessions() sessions = response.sessions } catch { - self.error = "Failed to load sessions" + self.error = "Failed to load sessions via relay" + sessions = [] } } - private func loadSessionsViaRelay() async { - // Relay mode can't use the REST API directly — the phone owns the - // WS connection and there's no relay message type for session listing. - // The watch shows an empty list with a hint to open on iPhone. - sessions = [] - } - // MARK: - Active Chat func setActiveChatVM(_ vm: ChatViewModel?) { @@ -184,53 +125,14 @@ final class AppState: ObservableObject { // MARK: - Send (mode-aware) func sendMessage(_ message: ClientMessage) async throws { - switch connectionMode { - case .direct: - try await wsClient?.send(message) - - case .relay: - let dict = try clientMessageToRelayDict(message) - let reply = try await relayClient.send(action: dict["action"] as? String ?? "", params: dict) - if let relayError = reply["error"] as? String { - throw RelayResponseError.serverRejected(relayError) - } - - case .none: + guard connectionMode == .relay else { throw ConnectionError.notConnected } - } - - // MARK: - Event Handling - - private func handleWSEvent(_ event: MitzoWSClient.Event) { - switch event { - case .stateChanged(let state): - connectionState = state - - if case .disconnected = state, connectionMode == .direct { - connectionMode = .none - reconnectWithBackoff() - } - - case .message(let msg): - activeChatVM?.handleMessage(msg) - - case .error(let err): - error = err.localizedDescription - } - } - private func reconnectWithBackoff() { - guard !isReconnecting else { return } - isReconnecting = true - reconnectAttempts += 1 - let baseDelay: UInt64 = 1_000_000_000 // 1s - let delay = min(baseDelay * UInt64(1 << min(reconnectAttempts - 1, 4)), Self.maxReconnectDelay) - - Task { - try? await Task.sleep(nanoseconds: delay) - await connect() - isReconnecting = false + let dict = try clientMessageToRelayDict(message) + let reply = try await relayClient.send(action: dict["action"] as? String ?? "", params: dict) + if let relayError = reply["error"] as? String { + throw RelayResponseError.serverRejected(relayError) } } @@ -317,10 +219,6 @@ final class AppState: ObservableObject { } } - // MARK: - Accessors - - func getWSClient() -> MitzoWSClient? { wsClient } - func getAPIClient() -> MitzoAPIClient? { apiClient } } enum ConnectionError: Error { diff --git a/frontend/ios/MitzoWatch/Services/ChatViewModel.swift b/frontend/ios/MitzoWatch/Services/ChatViewModel.swift index 5b37c9a9..362cd0d6 100644 --- a/frontend/ios/MitzoWatch/Services/ChatViewModel.swift +++ b/frontend/ios/MitzoWatch/Services/ChatViewModel.swift @@ -14,18 +14,15 @@ final class ChatViewModel: ObservableObject { let sessionId: String? private var resolvedSessionId: String? private(set) weak var appState: AppState? - private var wsClient: MitzoWSClient? init(sessionId: String?, appState: AppState? = nil) { self.sessionId = sessionId self.resolvedSessionId = sessionId self.appState = appState - self.wsClient = appState?.getWSClient() } func configure(appState: AppState) { self.appState = appState - self.wsClient = appState.getWSClient() appState.setActiveChatVM(self) } @@ -38,38 +35,34 @@ final class ChatViewModel: ObservableObject { func loadHistory() async { guard let sessionId = resolvedSessionId, - let apiClient = appState?.getAPIClient() else { return } + let appState else { return } do { - let finished: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) + let finished = try await appState.loadMessages(sessionId: sessionId) messages = finished.map { ChatMessage(from: $0) } } catch { // History load failure is non-fatal } - // Watch this session - if let client = wsClient { - try? await client.send(.watch(sessionId: sessionId)) - } + // Watch this session for live updates + try? await appState.sendMessage(.watch(sessionId: sessionId)) } // MARK: - Send Message func send(text: String) async { - guard let client = wsClient else { return } + guard let appState else { return } - // Add user message to display let userMsg = ChatMessage(role: .user, text: text) messages.append(userMsg) - // Send via WS let params = SendParams( sessionId: resolvedSessionId, prompt: text ) do { - try await client.send(.send(params)) + try await appState.sendMessage(.send(params)) isStreaming = true } catch { // Handle send failure @@ -80,7 +73,7 @@ final class ChatViewModel: ObservableObject { func respondToPermission(decision: PermissionDecision) async { guard let perm = permissionRequest, - let client = wsClient else { return } + let appState else { return } let params = PermissionResponseParams( sessionId: resolvedSessionId, @@ -88,7 +81,7 @@ final class ChatViewModel: ObservableObject { decision: decision ) - try? await client.send(.permissionResponse(params)) + try? await appState.sendMessage(.permissionResponse(params)) permissionRequest = nil } @@ -96,9 +89,9 @@ final class ChatViewModel: ObservableObject { func stop() async { guard let sessionId = resolvedSessionId, - let client = wsClient else { return } + let appState else { return } - try? await client.send(.stop(sessionId: sessionId)) + try? await appState.sendMessage(.stop(sessionId: sessionId)) } // MARK: - Process Server Messages diff --git a/frontend/ios/MitzoWatch/Views/ChatView.swift b/frontend/ios/MitzoWatch/Views/ChatView.swift index 1ae4c9ff..ea33d516 100644 --- a/frontend/ios/MitzoWatch/Views/ChatView.swift +++ b/frontend/ios/MitzoWatch/Views/ChatView.swift @@ -6,8 +6,9 @@ import MitzoShared struct ChatView: View { @EnvironmentObject var appState: AppState @StateObject private var viewModel: ChatViewModel - @StateObject private var voiceService = VoiceService() @State private var scrollProxy: ScrollViewProxy? + @State private var showTextInput = false + @State private var draftText = "" init(sessionId: String?) { _viewModel = StateObject(wrappedValue: ChatViewModel(sessionId: sessionId)) @@ -15,57 +16,92 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - // Message stream - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.messages) { message in - MessageBubble(message: message) - .id(message.id) - } + // Message stream — takes all available space + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + Color.clear.frame(height: 1).id("top") + + ForEach(viewModel.messages) { message in + MessageBubble(message: message) + .id(message.id) + } - // Live streaming content - if let stream = viewModel.currentStream { - StreamingBubble(stream: stream) - .id("streaming") - } + // Live streaming content + if let stream = viewModel.currentStream { + StreamingBubble(stream: stream) + .id("streaming") + } - // Tool status pill - if let status = viewModel.toolStatus { - ToolPill(status: status) - .id("tool") + // Tool status pill + if let status = viewModel.toolStatus { + ToolPill(status: status) + .id("tool") + } + + Color.clear.frame(height: 1).id("bottom") } + .padding(.horizontal, 4) } - .padding(.horizontal, 4) - .padding(.bottom, 8) - } - .onChange(of: viewModel.messages.count) { _, _ in - withAnimation { - proxy.scrollTo("streaming", anchor: .bottom) + .onChange(of: viewModel.messages.count) { _, _ in + withAnimation { + proxy.scrollTo("bottom", anchor: .bottom) + } } + .onAppear { scrollProxy = proxy } } - .onAppear { scrollProxy = proxy } - } - // Permission banner - if let perm = viewModel.permissionRequest { - PermissionBanner( - request: perm, - onAllow: { - Task { await viewModel.respondToPermission(decision: .once) } - }, - onDeny: { - Task { await viewModel.respondToPermission(decision: .deny) } + // Permission banner + if let perm = viewModel.permissionRequest { + PermissionBanner( + request: perm, + onAllow: { + Task { await viewModel.respondToPermission(decision: .once) } + }, + onDeny: { + Task { await viewModel.respondToPermission(decision: .deny) } + } + ) + } + + // Bottom bar: scroll nav + compose + HStack(spacing: 8) { + Button { + withAnimation { scrollProxy?.scrollTo("top", anchor: .top) } + } label: { + Image(systemName: "chevron.up") + .font(.system(size: 9, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + + Button { + withAnimation { scrollProxy?.scrollTo("bottom", anchor: .bottom) } + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) } - ) - } + .buttonStyle(.plain) + .foregroundStyle(.secondary) - Divider() + Spacer() - // Voice input bar - VoiceInputBar(voiceService: voiceService) { transcript in - Task { await viewModel.send(text: transcript) } - } + // Compose — opens watchOS native text input (dictation + scribble + keyboard) + Button { + showTextInput = true + } label: { + Image(systemName: "mic.fill") + .font(.system(size: 10)) + .foregroundStyle(.white) + .frame(width: 24, height: 24) + .background(Color.blue) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(height: 32) } .navigationTitle(viewModel.sessionId?.prefix(6).description ?? "New") .navigationBarTitleDisplayMode(.inline) @@ -82,6 +118,12 @@ struct ChatView: View { } } } + .sheet(isPresented: $showTextInput) { + ComposeSheet(draftText: $draftText) { text in + Task { await viewModel.send(text: text) } + showTextInput = false + } + } .task { viewModel.configure(appState: appState) await viewModel.loadHistory() diff --git a/frontend/ios/MitzoWatch/Views/ComposeSheet.swift b/frontend/ios/MitzoWatch/Views/ComposeSheet.swift new file mode 100644 index 00000000..50024230 --- /dev/null +++ b/frontend/ios/MitzoWatch/Views/ComposeSheet.swift @@ -0,0 +1,30 @@ +// Compose sheet — auto-focuses TextField to trigger watchOS native input +// (dictation + scribble + keyboard) + +import SwiftUI + +struct ComposeSheet: View { + @Binding var draftText: String + let onSend: (String) -> Void + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 8) { + TextField("Dictate or type", text: $draftText) + .font(.caption) + .focused($isFocused) + + if !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Send") { + let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) + draftText = "" + onSend(text) + } + .buttonStyle(.borderedProminent) + } + } + .onAppear { + isFocused = true + } + } +} diff --git a/frontend/ios/MitzoWatch/Views/SessionListView.swift b/frontend/ios/MitzoWatch/Views/SessionListView.swift index 4f013dd3..b7df45e1 100644 --- a/frontend/ios/MitzoWatch/Views/SessionListView.swift +++ b/frontend/ios/MitzoWatch/Views/SessionListView.swift @@ -36,7 +36,7 @@ struct SessionListView: View { } } .task { - await appState.loadSessions() + await appState.refreshSessions() } } diff --git a/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift b/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift index eab3daa9..2b394e70 100644 --- a/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift +++ b/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift @@ -1,4 +1,4 @@ -// Voice input bar — push-to-talk with live transcript +// Voice input bar — compact mic toggle for watchOS import SwiftUI @@ -7,65 +7,53 @@ struct VoiceInputBar: View { let onSend: (String) -> Void var body: some View { - VStack(spacing: 4) { + HStack(spacing: 8) { + // Cancel (visible while recording) + if voiceService.isRecording { + Button { + voiceService.cancelRecording() + } label: { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: 24, height: 24) + } + // Partial transcript - if !voiceService.partialTranscript.isEmpty { + if voiceService.isRecording && !voiceService.partialTranscript.isEmpty { Text(voiceService.partialTranscript) - .font(.caption2) + .font(.system(size: 10)) .foregroundStyle(.secondary) - .lineLimit(2) + .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) + } else if !voiceService.isRecording { + Spacer() } - HStack(spacing: 12) { - // Cancel (visible while recording) + // Mic button + Button { if voiceService.isRecording { - Button { - voiceService.cancelRecording() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - - // Mic button — hold to talk - Button { - // Toggle behavior for watch (long press is awkward) - if voiceService.isRecording { - voiceService.stopRecording { transcript in - if !transcript.isEmpty { - onSend(transcript) - } + voiceService.stopRecording { transcript in + if !transcript.isEmpty { + onSend(transcript) } - } else { - voiceService.startRecording() - } - } label: { - ZStack { - Circle() - .fill(voiceService.isRecording ? Color.red : Color.blue) - .frame(width: 44, height: 44) - - Image(systemName: voiceService.isRecording ? "stop.fill" : "mic.fill") - .font(.body) - .foregroundStyle(.white) } + } else { + voiceService.startRecording() } - .buttonStyle(.plain) - - // Recording indicator - if voiceService.isRecording { - Circle() - .fill(.red) - .frame(width: 8, height: 8) - .opacity(voiceService.isRecording ? 1 : 0) - .animation(.easeInOut(duration: 0.5).repeatForever(), value: voiceService.isRecording) - } + } label: { + Image(systemName: voiceService.isRecording ? "stop.fill" : "mic.fill") + .font(.caption) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(voiceService.isRecording ? Color.red : Color.blue) + .clipShape(Circle()) } - .padding(.vertical, 6) + .buttonStyle(.plain) } + .padding(.horizontal, 8) + .padding(.vertical, 4) } } diff --git a/frontend/src/hooks/useServiceHealth.ts b/frontend/src/hooks/useServiceHealth.ts index f9843aed..b3cf8569 100644 --- a/frontend/src/hooks/useServiceHealth.ts +++ b/frontend/src/hooks/useServiceHealth.ts @@ -1,9 +1,13 @@ -// SSE-driven service health — replaces per-hook polling for Yapper/ContexGin. +// SSE-driven service health with REST polling fallback. +// iOS WebKit can't establish SSE over self-signed HTTPS, so we poll /api/service-health too. -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { eventBus } from '../lib/event-bus-singleton'; +import { apiFetch } from '../lib/api-fetch'; import type { ServiceHealthPayload, ServiceHealthStatus } from '@mitzo/protocol'; +const POLL_INTERVAL_MS = 30_000; + export interface UseServiceHealthReturn { services: ServiceHealthStatus[]; yapper: ServiceHealthStatus | null; @@ -13,13 +17,49 @@ export interface UseServiceHealthReturn { export function useServiceHealth(): UseServiceHealthReturn { const [payload, setPayload] = useState({ services: [], checkedAt: 0 }); + const gotSseEvent = useRef(false); + // SSE listener (primary — works on Chrome, may fail on iOS) useEffect(() => { return eventBus.on('health', (data) => { + gotSseEvent.current = true; setPayload(data as ServiceHealthPayload); }); }, []); + // REST polling fallback — kicks in if SSE hasn't delivered after 5s + useEffect(() => { + let timer: ReturnType | null = null; + let cancelled = false; + + const poll = async () => { + try { + const res = await apiFetch('/api/service-health'); + if (res.ok && !cancelled) { + const data = await res.json(); + setPayload(data as ServiceHealthPayload); + } + } catch { + // ignore — server unreachable + } + }; + + // Wait 5s, then check if SSE has delivered. If not, start polling. + const startup = setTimeout(() => { + if (cancelled) return; + if (!gotSseEvent.current) { + poll(); // immediate first poll + timer = setInterval(poll, POLL_INTERVAL_MS); + } + }, 5_000); + + return () => { + cancelled = true; + clearTimeout(startup); + if (timer) clearInterval(timer); + }; + }, []); + const yapper = useMemo( () => payload.services.find((s) => s.name === 'yapper') ?? null, [payload], diff --git a/server/app.ts b/server/app.ts index 898d68d2..b41818de 100644 --- a/server/app.ts +++ b/server/app.ts @@ -530,6 +530,11 @@ app.get('/api/events', (req, res) => { req.on('close', () => sseRegistry.remove(clientId)); }); +// REST fallback for service health (iOS WebKit can't do SSE with self-signed certs) +app.get('/api/service-health', (_req, res) => { + res.json(healthMonitor?.getSnapshot() ?? { services: [], checkedAt: 0 }); +}); + // --- Task Board API --- app.get('/api/tasks', (_req, res) => { From 405125d1adc51bdefe169e55f2b22dde64f9f900 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 12:04:14 +0100 Subject: [PATCH 37/45] fix(watch): extract sessionId before Task to fix Sendable data race The message dict ([String: Any]) was captured inside a Task closure, which risks data races. Extract sessionId before the isolation boundary. Co-Authored-By: Claude Opus 4.6 --- .../Sources/MitzoShared/Networking/WatchRelay.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index 01835f5a..237e1f2b 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -58,6 +58,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } else { clientMsg = nil } + let relaySessionId = message["sessionId"] as? String ?? "" let reply = UnsafeSendable(replyHandler) Task { @@ -72,7 +73,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { reply.value(["ok": true]) case "get_messages": - let sessionId = message["sessionId"] as? String ?? "" + let sessionId = relaySessionId if let apiClient = state.getAPIClient() { do { let messages: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) From 64519db8043c19c7bf607b2e1a854bb85e16766a Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 12:23:25 +0100 Subject: [PATCH 38/45] fix(client): reduce SSE fallback delay + cache health for instant mic availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two iOS UX issues: 1. Mic button takes seconds to appear — reduced SSE fallback timeout from 5s to 2s 2. Mic button invisible on cold start — added localStorage cache so last-known Yapper health loads immediately, mic appears on first render if service was healthy in prior session Changes: - useServiceHealth: SSE_FALLBACK_DELAY_MS now 2s (was 5s) - getCachedHealth(): loads last-known health from localStorage on hook init - Both SSE and REST paths now cache health updates to localStorage Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/hooks/useServiceHealth.ts | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useServiceHealth.ts b/frontend/src/hooks/useServiceHealth.ts index b3cf8569..1d2b571a 100644 --- a/frontend/src/hooks/useServiceHealth.ts +++ b/frontend/src/hooks/useServiceHealth.ts @@ -7,6 +7,8 @@ import { apiFetch } from '../lib/api-fetch'; import type { ServiceHealthPayload, ServiceHealthStatus } from '@mitzo/protocol'; const POLL_INTERVAL_MS = 30_000; +const SSE_FALLBACK_DELAY_MS = 2_000; // Reduced from 5s for faster iOS fallback +const CACHE_KEY = 'mitzo:service-health'; export interface UseServiceHealthReturn { services: ServiceHealthStatus[]; @@ -15,19 +17,39 @@ export interface UseServiceHealthReturn { checkedAt: number; } +// Load cached health on boot for instant mic button availability +function getCachedHealth(): ServiceHealthPayload { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + return JSON.parse(cached) as ServiceHealthPayload; + } + } catch { + // ignore parse errors + } + return { services: [], checkedAt: 0 }; +} + export function useServiceHealth(): UseServiceHealthReturn { - const [payload, setPayload] = useState({ services: [], checkedAt: 0 }); + const [payload, setPayload] = useState(getCachedHealth); const gotSseEvent = useRef(false); // SSE listener (primary — works on Chrome, may fail on iOS) useEffect(() => { return eventBus.on('health', (data) => { gotSseEvent.current = true; - setPayload(data as ServiceHealthPayload); + const healthData = data as ServiceHealthPayload; + setPayload(healthData); + // Cache for next session + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(healthData)); + } catch { + // ignore quota errors + } }); }, []); - // REST polling fallback — kicks in if SSE hasn't delivered after 5s + // REST polling fallback — kicks in if SSE hasn't delivered after 2s useEffect(() => { let timer: ReturnType | null = null; let cancelled = false; @@ -36,22 +58,28 @@ export function useServiceHealth(): UseServiceHealthReturn { try { const res = await apiFetch('/api/service-health'); if (res.ok && !cancelled) { - const data = await res.json(); - setPayload(data as ServiceHealthPayload); + const data = (await res.json()) as ServiceHealthPayload; + setPayload(data); + // Cache for next session + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(data)); + } catch { + // ignore quota errors + } } } catch { // ignore — server unreachable } }; - // Wait 5s, then check if SSE has delivered. If not, start polling. + // Wait 2s, then check if SSE has delivered. If not, start polling. const startup = setTimeout(() => { if (cancelled) return; if (!gotSseEvent.current) { poll(); // immediate first poll timer = setInterval(poll, POLL_INTERVAL_MS); } - }, 5_000); + }, SSE_FALLBACK_DELAY_MS); return () => { cancelled = true; From 959af61d4fca8c2fefac5f68a49ec26c208e77bf Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 12:28:44 +0100 Subject: [PATCH 39/45] feat(watch): iOS lifecycle hooks for watch relay background/foreground --- frontend/ios/App/App/AppDelegate.swift | 8 ++-- frontend/ios/App/App/WatchAuthBridge.swift | 45 +++++++++++++++++++ frontend/ios/App/WatchRelayCoordinator.swift | 18 ++++++-- .../MitzoShared/Networking/WatchRelay.swift | 21 +++++---- 4 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 frontend/ios/App/App/WatchAuthBridge.swift diff --git a/frontend/ios/App/App/AppDelegate.swift b/frontend/ios/App/App/AppDelegate.swift index 24ece850..17e3f588 100644 --- a/frontend/ios/App/App/AppDelegate.swift +++ b/frontend/ios/App/App/AppDelegate.swift @@ -5,9 +5,10 @@ import Capacitor class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private let watchRelay = WatchRelayCoordinator() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + watchRelay.start() return true } @@ -17,12 +18,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + watchRelay.suspend() } func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + watchRelay.reconnect() } func applicationDidBecomeActive(_ application: UIApplication) { diff --git a/frontend/ios/App/App/WatchAuthBridge.swift b/frontend/ios/App/App/WatchAuthBridge.swift new file mode 100644 index 00000000..199bf537 --- /dev/null +++ b/frontend/ios/App/App/WatchAuthBridge.swift @@ -0,0 +1,45 @@ +// Capacitor plugin that bridges web auth tokens into the native shared Keychain. +// When the web app logs in, it calls WatchAuthBridge.saveToken() so the +// watch can read the JWT from the shared Keychain access group. + +import Capacitor +import MitzoShared + +@objc(WatchAuthBridge) +public class WatchAuthBridge: CAPPlugin, CAPBridgedPlugin { + public let identifier = "WatchAuthBridge" + public let jsName = "WatchAuthBridge" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "saveToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearToken", returnType: CAPPluginReturnPromise), + ] + + private let authManager = AuthManager() + + @objc func saveToken(_ call: CAPPluginCall) { + guard let token = call.getString("token") else { + call.reject("Missing token") + return + } + + Task { + do { + try await authManager.saveToken(token) + call.resolve() + } catch { + call.reject("Failed to save token: \(error.localizedDescription)") + } + } + } + + @objc func clearToken(_ call: CAPPluginCall) { + Task { + do { + try await authManager.clearToken() + call.resolve() + } catch { + call.reject("Failed to clear token: \(error.localizedDescription)") + } + } + } +} diff --git a/frontend/ios/App/WatchRelayCoordinator.swift b/frontend/ios/App/WatchRelayCoordinator.swift index dc13b2a8..a2cdb255 100644 --- a/frontend/ios/App/WatchRelayCoordinator.swift +++ b/frontend/ios/App/WatchRelayCoordinator.swift @@ -21,11 +21,18 @@ final class WatchRelayCoordinator: @unchecked Sendable { } func start() { - Task { await connect() } + // Activate WCSession unconditionally so the watch relay works + // even before auth. This lets the auth_token relay bootstrap + // the token, and list_sessions/get_messages return proper errors + // instead of silently hanging with no delegate. + let apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + watchRelay.activate(wsClient: nil, apiClient: apiClient) + + Task { await connectWS() } } func reconnect() { - Task { await connect() } + Task { await connectWS() } } func suspend() { @@ -40,7 +47,9 @@ final class WatchRelayCoordinator: @unchecked Sendable { } } - private func connect() async { + /// Connect the native WS (for forwarding server events to watch). + /// WCSession is already active from start() — this only adds WS. + private func connectWS() async { guard let token = try? await authManager.getToken() else { return } var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! @@ -51,8 +60,9 @@ final class WatchRelayCoordinator: @unchecked Sendable { guard let wsURL = components.url else { return } let client = MitzoWSClient(url: wsURL) + let apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) lock.withLock { wsClient = client } - watchRelay.activate(wsClient: client) + watchRelay.activate(wsClient: client, apiClient: apiClient) let relay = watchRelay await client.connect { event in diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index 237e1f2b..c29cdc51 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -26,9 +26,9 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { super.init() } - public func activate(wsClient: MitzoWSClient, apiClient: MitzoAPIClient? = nil) { - state.setWSClient(wsClient) - state.setAPIClient(apiClient) + public func activate(wsClient: MitzoWSClient? = nil, apiClient: MitzoAPIClient? = nil) { + if let wsClient { state.setWSClient(wsClient) } + if let apiClient { state.setAPIClient(apiClient) } guard WCSession.isSupported() else { return } WCSession.default.delegate = self @@ -50,30 +50,29 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { return } - // Extract values before crossing the Task isolation boundary + // Extract ALL values before crossing the Task isolation boundary // so we don't capture the non-Sendable [String: Any] dict. - let clientMsg: ClientMessage? + let clientMsg: UnsafeSendable if type == "send" { - clientMsg = try? decodeClientMessage(from: message) + clientMsg = UnsafeSendable(try? decodeClientMessage(from: message)) } else { - clientMsg = nil + clientMsg = UnsafeSendable(nil) } - let relaySessionId = message["sessionId"] as? String ?? "" + let sessionId = message["sessionId"] as? String ?? "" let reply = UnsafeSendable(replyHandler) Task { do { switch type { case "send": - guard let clientMsg else { + guard let msg = clientMsg.value else { reply.value(["error": "invalid message"]) return } - try await state.getWSClient()?.send(clientMsg) + try await state.getWSClient()?.send(msg) reply.value(["ok": true]) case "get_messages": - let sessionId = relaySessionId if let apiClient = state.getAPIClient() { do { let messages: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) From 23c4e6691dc0b66d9b6d73630ba09d418181f60a Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 12:28:57 +0100 Subject: [PATCH 40/45] feat(watch): add watch auth bridge and frontend integration --- frontend/ios/App/WatchAuthBridge.swift | 45 ------------------- frontend/ios/MitzoWatch/Info.plist | 5 +++ .../MitzoWatch/Services/VoiceService.swift | 2 +- frontend/src/App.tsx | 9 +++- frontend/src/components/TabBar.tsx | 15 +++++++ frontend/src/lib/biometric.ts | 3 ++ frontend/src/lib/watch-auth.ts | 29 ++++++++++++ frontend/src/pages/Login.tsx | 2 + frontend/src/styles/global.css | 4 ++ server/index.ts | 9 ++++ 10 files changed, 76 insertions(+), 47 deletions(-) delete mode 100644 frontend/ios/App/WatchAuthBridge.swift create mode 100644 frontend/src/lib/watch-auth.ts diff --git a/frontend/ios/App/WatchAuthBridge.swift b/frontend/ios/App/WatchAuthBridge.swift deleted file mode 100644 index 199bf537..00000000 --- a/frontend/ios/App/WatchAuthBridge.swift +++ /dev/null @@ -1,45 +0,0 @@ -// Capacitor plugin that bridges web auth tokens into the native shared Keychain. -// When the web app logs in, it calls WatchAuthBridge.saveToken() so the -// watch can read the JWT from the shared Keychain access group. - -import Capacitor -import MitzoShared - -@objc(WatchAuthBridge) -public class WatchAuthBridge: CAPPlugin, CAPBridgedPlugin { - public let identifier = "WatchAuthBridge" - public let jsName = "WatchAuthBridge" - public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "saveToken", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "clearToken", returnType: CAPPluginReturnPromise), - ] - - private let authManager = AuthManager() - - @objc func saveToken(_ call: CAPPluginCall) { - guard let token = call.getString("token") else { - call.reject("Missing token") - return - } - - Task { - do { - try await authManager.saveToken(token) - call.resolve() - } catch { - call.reject("Failed to save token: \(error.localizedDescription)") - } - } - } - - @objc func clearToken(_ call: CAPPluginCall) { - Task { - do { - try await authManager.clearToken() - call.resolve() - } catch { - call.reject("Failed to clear token: \(error.localizedDescription)") - } - } - } -} diff --git a/frontend/ios/MitzoWatch/Info.plist b/frontend/ios/MitzoWatch/Info.plist index 79a8a33c..25ffa18b 100644 --- a/frontend/ios/MitzoWatch/Info.plist +++ b/frontend/ios/MitzoWatch/Info.plist @@ -26,5 +26,10 @@ WKCompanionAppBundleIdentifier com.mitzo.app + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/frontend/ios/MitzoWatch/Services/VoiceService.swift b/frontend/ios/MitzoWatch/Services/VoiceService.swift index f66bb917..2846364d 100644 --- a/frontend/ios/MitzoWatch/Services/VoiceService.swift +++ b/frontend/ios/MitzoWatch/Services/VoiceService.swift @@ -17,7 +17,7 @@ final class VoiceService: ObservableObject { // Configurable via UserDefaults; defaults to Tailscale hostname var yapperURL: URL { let stored = UserDefaults.standard.string(forKey: "mitzo_yapper_url") - return URL(string: stored ?? "http://mitzo.tail:8700")! + return URL(string: stored ?? "http://100.91.50.57:8700")! } init() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf2e9ef1..0280d010 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { apiFetch } from './lib/api-fetch'; import { hideSplash } from './lib/splash'; +import { saveTokenToWatch } from './lib/watch-auth'; import { Login } from './pages/Login'; import { SessionList } from './pages/SessionList'; import { ChatView } from './pages/ChatView'; @@ -21,7 +22,13 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { const [auth, setAuth] = useState<'loading' | 'ok' | 'denied'>('loading'); useEffect(() => { apiFetch('/api/auth/check') - .then((r) => setAuth(r.ok ? 'ok' : 'denied')) + .then((r) => { + setAuth(r.ok ? 'ok' : 'denied'); + if (r.ok) { + const token = localStorage.getItem('mitzo_auth_token'); + if (token) saveTokenToWatch(token); + } + }) .catch(() => setAuth('denied')) .finally(() => hideSplash()); }, []); diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index ae050cbd..44c63821 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -2,6 +2,8 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useTabBadges } from '../hooks/useTabBadges'; import { useIsDesktop } from '../hooks/useMediaQuery'; import { useTheme } from '../hooks/useTheme'; +import { deleteCredentials } from '../lib/biometric'; +import { clearWatchToken } from '../lib/watch-auth'; import { useState } from 'react'; interface Tab { @@ -77,6 +79,19 @@ export function TabBar() { ? 'System Mode' : 'Dark Mode'} +

+
)} diff --git a/frontend/src/lib/biometric.ts b/frontend/src/lib/biometric.ts index 4f4ea4e6..0e902aa7 100644 --- a/frontend/src/lib/biometric.ts +++ b/frontend/src/lib/biometric.ts @@ -102,6 +102,9 @@ export async function biometricLogin(apiBaseUrl = ''): Promise { } localStorage.setItem(AUTH_TOKEN_KEY, token); + // Also save to native shared Keychain for Apple Watch + const { saveTokenToWatch } = await import('./watch-auth'); + await saveTokenToWatch(token); return token; } catch { return null; diff --git a/frontend/src/lib/watch-auth.ts b/frontend/src/lib/watch-auth.ts new file mode 100644 index 00000000..46e947a8 --- /dev/null +++ b/frontend/src/lib/watch-auth.ts @@ -0,0 +1,29 @@ +// Bridges web auth tokens into the native shared Keychain for Apple Watch. +// Calls the WatchAuthBridge Capacitor plugin on iOS; no-ops on web/Android. + +import { Capacitor, registerPlugin } from '@capacitor/core'; + +interface WatchAuthBridgePlugin { + saveToken(options: { token: string }): Promise; + clearToken(): Promise; +} + +const WatchAuthBridge = registerPlugin('WatchAuthBridge'); + +export async function saveTokenToWatch(token: string): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'ios') return; + try { + await WatchAuthBridge.saveToken({ token }); + } catch { + // Plugin not available or save failed — non-fatal + } +} + +export async function clearWatchToken(): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'ios') return; + try { + await WatchAuthBridge.clearToken(); + } catch { + // Plugin not available — non-fatal + } +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index c6452e3d..9ccaa4de 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -7,6 +7,7 @@ import { biometricLogin, saveCredentials, } from '../lib/biometric'; +import { saveTokenToWatch } from '../lib/watch-auth'; import { notifySuccess } from '../lib/haptics'; export function Login() { @@ -59,6 +60,7 @@ export function Login() { if (data.token) { localStorage.setItem('mitzo_auth_token', data.token); await saveCredentials(data.token); + await saveTokenToWatch(data.token); } navigate('/'); } else { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ca6ad293..0f98aa61 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -331,6 +331,10 @@ textarea:focus { background: var(--hover); } +.tab-bar-more-item--danger { + color: #ef4444; +} + /* ── EmptyState ─────────────────────────────────────────── */ .empty-state { diff --git a/server/index.ts b/server/index.ts index 2d8ef3c6..2e7a1ca5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -860,6 +860,15 @@ checkPort(PORT).then((inUse) => { process.exit(1); } + // Plain HTTP listener for watchOS (can't trust self-signed TLS certs) + if (USE_TLS) { + const httpServer = createServer(app); + const HTTP_PORT = PORT + 1; + httpServer.listen(HTTP_PORT, () => { + log.info(`HTTP listener for watchOS on http://localhost:${HTTP_PORT}`); + }); + } + server.listen(PORT, () => { const protocol = USE_TLS ? 'https' : 'http'; log.info(`Chat Agent running on ${protocol}://localhost:${PORT}${USE_TLS ? ' (TLS)' : ''}`); From 676a9ab0eb949e6db96c9425f03919b4e7f25da8 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 13:02:22 +0100 Subject: [PATCH 41/45] fix(watch): use captured locals in @Sendable Task closure Capture state and authManager into local lets before the Task boundary and mark closure @Sendable to satisfy strict concurrency checking. Co-Authored-By: Claude Opus 4.6 --- .../Sources/MitzoShared/Networking/WatchRelay.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index c29cdc51..08fd6f32 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -60,8 +60,10 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } let sessionId = message["sessionId"] as? String ?? "" let reply = UnsafeSendable(replyHandler) + let capturedState = state + let capturedAuthManager = authManager - Task { + Task { @Sendable in do { switch type { case "send": @@ -69,11 +71,11 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { reply.value(["error": "invalid message"]) return } - try await state.getWSClient()?.send(msg) + try await capturedState.getWSClient()?.send(msg) reply.value(["ok": true]) case "get_messages": - if let apiClient = state.getAPIClient() { + if let apiClient = capturedState.getAPIClient() { do { let messages: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) let data = try JSONEncoder().encode(messages) @@ -90,7 +92,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } case "list_sessions": - if let apiClient = state.getAPIClient() { + if let apiClient = capturedState.getAPIClient() { do { let response = try await apiClient.getSessions() let data = try JSONEncoder().encode(response) @@ -107,7 +109,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } case "auth_token": - if let token = try? await authManager.getToken() { + if let token = try? await capturedAuthManager.getToken() { reply.value(["token": token]) } else { reply.value(["error": "no_token"]) From 01b810c8bf734c459bf593ead0ae4b1d1d5a2ad1 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 13:05:01 +0100 Subject: [PATCH 42/45] fix(watch): activate WCSession in init before auth/login WatchRelayHost now activates WCSession immediately when the iPhone app starts, not waiting for user login. This lets the watch send relay messages (like auth_token request) even before the iPhone user logs in. Co-Authored-By: Claude Opus 4.6 --- frontend/ios/App/App/WatchRelayCoordinator.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/ios/App/App/WatchRelayCoordinator.swift b/frontend/ios/App/App/WatchRelayCoordinator.swift index 4127e537..72f09abe 100644 --- a/frontend/ios/App/App/WatchRelayCoordinator.swift +++ b/frontend/ios/App/App/WatchRelayCoordinator.swift @@ -18,6 +18,8 @@ final class WatchRelayCoordinator: @unchecked Sendable { init() { watchRelay = WatchRelayHost(authManager: authManager) + // Activate WCSession immediately so watch can reach us even before login + watchRelay.activate(wsClient: nil, apiClient: nil) } func start() { @@ -54,6 +56,7 @@ final class WatchRelayCoordinator: @unchecked Sendable { lock.withLock { wsClient = client } let api = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + // Update relay with authenticated clients (WCSession already activated in init) watchRelay.activate(wsClient: client, apiClient: api) let relay = watchRelay From 5e1c3dbb8c84667846b54696dd7d2f024a6f5542 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 13:08:20 +0100 Subject: [PATCH 43/45] chore(watch): add logging to WatchRelay activation and message handling Add print statements to diagnose WCSession activation and message delivery. Co-Authored-By: Claude Opus 4.6 --- .../Sources/MitzoShared/Networking/WatchRelay.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index 08fd6f32..6d81fe47 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -30,14 +30,20 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { if let wsClient { state.setWSClient(wsClient) } if let apiClient { state.setAPIClient(apiClient) } - guard WCSession.isSupported() else { return } + guard WCSession.isSupported() else { + print("[WatchRelay] WCSession not supported") + return + } + print("[WatchRelay] Activating WCSession, delegate=\(WCSession.default.delegate == nil ? "nil" : "set")") WCSession.default.delegate = self WCSession.default.activate() } // MARK: - WCSessionDelegate - public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + print("[WatchRelay] Activation complete: state=\(activationState.rawValue), error=\(String(describing: error))") + } public func sessionDidBecomeInactive(_ session: WCSession) {} public func sessionDidDeactivate(_ session: WCSession) { @@ -45,10 +51,13 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } public func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + print("[WatchRelay] Received message: \(message)") guard let type = message["_relay"] as? String else { + print("[WatchRelay] Missing _relay type, sending error") replyHandler(["error": "missing _relay type"]) return } + print("[WatchRelay] Relay type: \(type)") // Extract ALL values before crossing the Task isolation boundary // so we don't capture the non-Sendable [String: Any] dict. From de652e66485e560cee51a74ea85e9ee1b36aa6ec Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 13:44:00 +0100 Subject: [PATCH 44/45] feat(session): add user-initiated session close + visual status indicators Allow users to explicitly close sessions via a close button in the chat header or /close command. Reuses existing closeout flow (agent commits, writes memory, summarizes) with a shorter 2-minute timeout. Session list now shows close status: checkmark (user-closed), stop icon (auto-closed), or empty-set (abandoned). Changes across protocol, harness, server, client, and frontend: - session_close WS message + session_close_ack response - closed_by field on SessionMeta, SessionIndexEntry, event store - closeSessionByUser() in chat.ts with USER_CLOSEOUT_PROMPT - userClosing tracking in SessionRegistry - /close native command - closedBy exposed in GET /api/sessions - Close button in desktop + mobile chat headers - Status indicators in SessionPanel Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/SessionPanel.tsx | 11 ++- frontend/src/pages/ChatView.tsx | 10 +++ frontend/src/pages/DesktopChatView.tsx | 10 +++ frontend/src/styles/desktop.css | 40 +++++++++++ frontend/src/styles/global.css | 18 +++++ packages/client/src/protocol-parser.ts | 11 +++ packages/client/src/store.ts | 7 ++ packages/harness/src/constants.ts | 2 + packages/harness/src/index.ts | 1 + packages/harness/src/session-registry.ts | 16 +++++ packages/protocol/src/event-store.ts | 18 +++++ packages/protocol/src/index.ts | 2 + packages/protocol/src/types.ts | 4 ++ packages/protocol/src/ws-schemas-v2.ts | 6 ++ server/app.ts | 1 + server/chat.ts | 89 +++++++++++++++++++++++- server/constants.ts | 1 + server/native-commands.ts | 10 +++ server/session-index.ts | 2 + server/ws-handler-v2.ts | 61 ++++++++++++++++ 20 files changed, 317 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SessionPanel.tsx b/frontend/src/components/SessionPanel.tsx index 2afd994d..e45a516f 100644 --- a/frontend/src/components/SessionPanel.tsx +++ b/frontend/src/components/SessionPanel.tsx @@ -71,11 +71,18 @@ export function SessionPanel({ activeSessionId, onSelectSession, onNewChat }: Se className={`session-panel-item${s.id === activeSessionId ? ' session-panel-item--active' : ''}`} onClick={() => onSelectSession(s.id)} > - {s.isActive && ( + {s.isActive ? ( - )} + ) : s.closedBy ? ( + + {s.closedBy === 'user' ? '\u2713' : s.closedBy === 'auto' ? '\u23F9' : '\u2205'} + + ) : null}
{s.summary || 'Untitled session'} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 5f42f588..d93a223c 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -44,6 +44,7 @@ export function ChatView() { const storeRespondToPermission = useMitzoStore((s) => s.respondToPermission); const storeSwitchSession = useMitzoStore((s) => s.switchSession); const storeNewSession = useMitzoStore((s) => s.newSession); + const storeCloseSession = useMitzoStore((s) => s.closeSession); const storeSetMode = useMitzoStore((s) => s.setMode); const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); @@ -247,6 +248,15 @@ export function ChatView() { {isolation ? '\u{1f512}' : '\u{1f513}'} )} + {activeSessionId && ( + + )} s.respondToPermission); const storeSwitchSession = useMitzoStore((s) => s.switchSession); const storeNewSession = useMitzoStore((s) => s.newSession); + const storeCloseSession = useMitzoStore((s) => s.closeSession); const storeSetMode = useMitzoStore((s) => s.setMode); const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); @@ -221,6 +222,15 @@ export function DesktopChatView() { {isolation ? '\u{1f512}' : '\u{1f513}'} )} + {activeSessionId && ( + + )} >(); private closeoutTimers = new Map>(); private closingOut = new Set(); + private userClosing = new Set(); private closeoutHandler: CloseoutHandler | null = null; private suspended = new Set(); private suspendBuffers = new Map[]>(); @@ -82,6 +83,17 @@ export class SessionRegistry { return this.closingOut.has(clientId); } + /** Mark a session as being closed by the user. */ + markUserClose(clientId: string): void { + this.userClosing.add(clientId); + this.closingOut.add(clientId); + } + + /** Check if a session close was user-initiated. */ + isUserClose(clientId: string): boolean { + return this.userClosing.has(clientId); + } + register( clientId: string, init: Omit< @@ -195,6 +207,7 @@ export class SessionRegistry { this.clearDetachTimer(clientId); this.clearCloseoutTimer(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); this.clearSuspendState(clientId); return true; } @@ -302,6 +315,7 @@ export class SessionRegistry { this.sessions.delete(clientId); this.attached.delete(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); } /** @@ -317,6 +331,7 @@ export class SessionRegistry { this.sessions.delete(clientId); this.attached.delete(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); } /** @@ -333,6 +348,7 @@ export class SessionRegistry { } this.closeoutTimers.clear(); this.closingOut.clear(); + this.userClosing.clear(); for (const timer of this.suspendTimers.values()) { clearTimeout(timer); diff --git a/packages/protocol/src/event-store.ts b/packages/protocol/src/event-store.ts index d16035e9..9200d7af 100644 --- a/packages/protocol/src/event-store.ts +++ b/packages/protocol/src/event-store.ts @@ -42,6 +42,7 @@ interface SessionRow { duration_api_ms: number; goal_id: string | null; telos_task_id: string | null; + closed_by: string | null; created_at: number; updated_at: number; } @@ -99,6 +100,7 @@ export class EventStore { this.migratePromptTracking(db); this.migrateUsageTracking(db); this.migrateWorktreeTracking(db); + this.migrateCloseTracking(db); this.log.info('EventStore initialized', { dbPath }); @@ -200,6 +202,15 @@ export class EventStore { } } + private migrateCloseTracking(db: Database.Database): void { + const columns = db.prepare("PRAGMA table_info('sessions')").all() as Array<{ name: string }>; + const columnNames = new Set(columns.map((c) => c.name)); + if (!columnNames.has('closed_by')) { + db.exec('ALTER TABLE sessions ADD COLUMN closed_by TEXT'); + this.log.info('migrated sessions table: added closed_by'); + } + } + close(): void { if (this.db) { this.db.close(); @@ -266,6 +277,10 @@ export class EventStore { fields.push('wt_id = ?'); values.push(meta.wtId); } + if (meta.closedBy !== undefined) { + fields.push('closed_by = ?'); + values.push(meta.closedBy); + } if (meta.updatedAt !== undefined) { fields.push('updated_at = ?'); values.push(meta.updatedAt); @@ -288,6 +303,7 @@ export class EventStore { 'wt_id', 'goal_id', 'telos_task_id', + 'closed_by', ]; const vals: unknown[] = [ meta.sessionId, @@ -300,6 +316,7 @@ export class EventStore { meta.wtId ?? null, meta.goalId ?? null, meta.telosTaskId ?? null, + meta.closedBy ?? null, ]; if (meta.updatedAt !== undefined) { cols.push('updated_at'); @@ -513,6 +530,7 @@ function rowToSession(row: SessionRow): SessionMeta { durationApiMs: row.duration_api_ms ?? 0, goalId: row.goal_id ?? null, telosTaskId: row.telos_task_id ?? null, + closedBy: (row.closed_by as SessionMeta['closedBy']) ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index f8fcc1c5..98ab8d02 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -15,6 +15,7 @@ export type { PermissionRequest, ImageAttachment, Session, + SessionClosedBy, StoredEvent, SessionMeta, SessionSearchResult, @@ -75,6 +76,7 @@ export { UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2InterruptMessage, V2StopMessage, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index d597e61e..0a31f106 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -105,6 +105,8 @@ export interface ImageAttachment { // --- Session (client-facing) --- +export type SessionClosedBy = 'user' | 'auto' | 'abandoned'; + export interface Session { id: string; summary: string; @@ -115,6 +117,7 @@ export interface Session { totalTokens?: number; numTurns?: number; telosTaskId?: string; + closedBy?: SessionClosedBy; } // --- Session activity types (SSE event bus) --- @@ -194,6 +197,7 @@ export interface SessionMeta { durationApiMs: number; goalId: string | null; telosTaskId: string | null; + closedBy: SessionClosedBy | null; createdAt: number; updatedAt: number; } diff --git a/packages/protocol/src/ws-schemas-v2.ts b/packages/protocol/src/ws-schemas-v2.ts index 082df655..7e30f945 100644 --- a/packages/protocol/src/ws-schemas-v2.ts +++ b/packages/protocol/src/ws-schemas-v2.ts @@ -65,6 +65,11 @@ export const SessionSuspendMessage = z.object({ ), }); +export const SessionCloseMessage = z.object({ + type: z.literal('session_close'), + sessionId: z.string().min(1), +}); + // ─── Chat messages (session-scoped) ───────────────────────────────────────── // sessionId is nullable on send (null = start new session) but required on @@ -121,6 +126,7 @@ export const IncomingWsMessageV2 = z.discriminatedUnion('type', [ UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2InterruptMessage, V2StopMessage, diff --git a/server/app.ts b/server/app.ts index b41818de..8fcadb48 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1007,6 +1007,7 @@ app.get('/api/sessions', async (req, res) => { ? meta.inputTokens + meta.outputTokens + meta.cacheReadTokens + meta.cacheCreationTokens : undefined, numTurns: meta?.numTurns, + closedBy: meta?.closedBy ?? undefined, }; }); } diff --git a/server/chat.ts b/server/chat.ts index 3a91c572..fec00f44 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -30,7 +30,7 @@ import { loadProjectHooks } from './hook-bridge.js'; import { buildPermissionHandler } from './permission-handler.js'; import { runQueryLoop, broadcastToObservers } from './query-loop.js'; import { AsyncQueue } from './async-queue.js'; -import { GIT_BRANCH_TIMEOUT_MS, SESSION_PAGE_SIZE, SESSION_MESSAGES_LIMIT } from './constants.js'; +import { GIT_BRANCH_TIMEOUT_MS, SESSION_PAGE_SIZE, SESSION_MESSAGES_LIMIT, USER_CLOSEOUT_TIMEOUT_MS } from './constants.js'; import { INTERNAL_TOKEN } from './internal-token.js'; import { buildTaskSystemPrompt } from './task-context.js'; import type { TaskStore } from './task-store.js'; @@ -1081,6 +1081,7 @@ function _closeoutSessionInner(clientId: string): void { try { finalizeCloseout(BASE_REPO, session.wtId, { status: 'abandoned', + closed_by: 'abandoned', tokens_used: session.cumulativeSessionTokens, cost_usd: session.cumulativeCostUsd, }); @@ -1105,9 +1106,15 @@ function _closeoutSessionInner(clientId: string): void { const wtId = session.wtId; const onAbort = () => { const status = registry.isClosingOut(clientId) ? 'abandoned' : 'closed'; + const closedBy = registry.isUserClose(clientId) + ? 'user' + : status === 'abandoned' + ? 'abandoned' + : 'auto'; try { finalizeCloseout(BASE_REPO, wtId, { status, + closed_by: closedBy, tokens_used: session.cumulativeSessionTokens, cost_usd: session.cumulativeCostUsd, }); @@ -1122,6 +1129,86 @@ function _closeoutSessionInner(clientId: string): void { // Wire closeout handler on the registry registry.setCloseoutHandler(closeoutSession); +const USER_CLOSEOUT_PROMPT = `The user has closed this session. +Please perform session closeout: + +1. If there is uncommitted work in any worktree, commit it now with a descriptive message +2. If there are memory-worthy observations, decisions, or patterns — write them to memory/Observations/ or memory/Decisions/ +3. Write a 2-3 sentence summary of what was accomplished and what remains unfinished — output it as your final chat message so it appears in the conversation history +4. Do not ask for confirmation — just do it`; + +/** + * User-initiated session close. Triggers the same closeout flow as + * auto-close but with a shorter timeout (2 minutes) and marks the + * session as closed by the user. + */ +export function closeSessionByUser(clientId: string): void { + withSpan('session.close_by_user', { 'session.clientId': clientId }, () => { + const session = registry.get(clientId); + if (!session) return; + + // Mark as user-initiated close in the registry + registry.markUserClose(clientId); + + if (!session.inputQueue) { + // No active agent — finalize immediately + if (session.wtId) { + try { + finalizeCloseout(BASE_REPO, session.wtId, { + status: 'closed', + closed_by: 'user', + tokens_used: session.cumulativeSessionTokens, + cost_usd: session.cumulativeCostUsd, + }); + } catch { + // best-effort + } + } + if (session.sessionId) { + eventStore.upsertSession({ sessionId: session.sessionId, isActive: false, closedBy: 'user' }); + } + registry.remove(clientId); + return; + } + + log.info('user-initiated closeout', { clientId, wtId: session.wtId }); + + // Inject closeout prompt + session.inputQueue.push(makeUserMessage(USER_CLOSEOUT_PROMPT, 'now')); + + // Register abort listener to finalize with closed_by: 'user' + if (session.wtId) { + const wtId = session.wtId; + const onAbort = () => { + try { + finalizeCloseout(BASE_REPO, wtId, { + status: 'closed', + closed_by: 'user', + tokens_used: session.cumulativeSessionTokens, + cost_usd: session.cumulativeCostUsd, + }); + } catch { + // best-effort + } + }; + session.abortController.signal.addEventListener('abort', onAbort, { once: true }); + } + + // Mark inactive in event store + if (session.sessionId) { + eventStore.upsertSession({ sessionId: session.sessionId, closedBy: 'user' }); + } + + // Set a shorter timeout — 2 minutes instead of 10 + setTimeout(() => { + if (registry.isActive(clientId) && registry.isUserClose(clientId)) { + log.info('user closeout timeout, aborting', { clientId }); + registry.abort(clientId); + } + }, USER_CLOSEOUT_TIMEOUT_MS); + }); +} + export function stopChat(clientId: string) { withSpan('session.stop', { 'session.clientId': clientId }, () => { const session = registry.get(clientId); diff --git a/server/constants.ts b/server/constants.ts index d2599e4b..744c94b2 100644 --- a/server/constants.ts +++ b/server/constants.ts @@ -20,6 +20,7 @@ export { DETACHED_TTL_MS, CLOSEOUT_LEAD_MS, CLOSEOUT_TIMEOUT_MS, + USER_CLOSEOUT_TIMEOUT_MS, PERMISSION_TIMEOUT_MS, NTFY_NOTIFICATION_DELAY_MS, } from '@mitzo/harness'; diff --git a/server/native-commands.ts b/server/native-commands.ts index 82ffb227..51e8ca03 100644 --- a/server/native-commands.ts +++ b/server/native-commands.ts @@ -12,6 +12,7 @@ export class NativeCommandRegistry { constructor() { this.commands.set('skills', skillsCommand); + this.commands.set('close', closeCommand); } has(name: string): boolean { @@ -81,3 +82,12 @@ function skillsCommand(args: string, skillRegistry: SkillRegistry): NativeComman return { command: 'skills', content: lines.join('\n') }; } + +// --- /close command --- + +function closeCommand(): NativeCommandResult { + return { + command: 'close', + content: 'Closing session... The agent will commit any uncommitted work and write a summary.', + }; +} diff --git a/server/session-index.ts b/server/session-index.ts index c1892ce7..61083e09 100644 --- a/server/session-index.ts +++ b/server/session-index.ts @@ -18,6 +18,7 @@ export interface SessionIndexEntry { last_title?: string; repos?: SessionIndexRepo[]; status?: 'active' | 'closed' | 'abandoned'; + closed_by?: 'user' | 'auto' | 'abandoned'; has_uncommitted?: boolean; closeout_summary?: string; tokens_used?: number; @@ -132,6 +133,7 @@ export function finalizeCloseout( wtId: string, fields: { status: 'closed' | 'abandoned'; + closed_by?: 'user' | 'auto' | 'abandoned'; tokens_used?: number; cost_usd?: number; has_uncommitted?: boolean; diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index 01c83e25..61202c89 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -17,6 +17,7 @@ import { UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2StopMessage, V2InterruptMessage, @@ -29,6 +30,7 @@ type WatchMsg = z.infer; type UnwatchMsg = z.infer; type SwitchSessionMsg = z.infer; type SessionSuspendMsg = z.infer; +type SessionCloseMsg = z.infer; type SendMsg = z.infer; type StopMsg = z.infer; type InterruptMsg = z.infer; @@ -43,6 +45,7 @@ import { sendToChat, interruptChat, stopChat, + closeSessionByUser, isActive, reattachChat, rekeyChat, @@ -658,6 +661,61 @@ export function handleSessionSuspend( ); } +export function handleSessionClose( + connectionId: string, + msg: SessionCloseMsg, + ctx: V2HandlerContext, +): void { + withSpan( + 'ws.session_close', + { 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId }, + () => { + const found = ctx.sessionRegistry.findBySessionId(msg.sessionId); + const conn = ctx.connRegistry.get(connectionId); + + if (!found) { + log.warn('close: session not found', { connectionId, sessionId: msg.sessionId }); + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: false, + reason: 'Session not found', + }); + return; + } + + const ownerConnection = getOwnerConnection(found.clientId); + if (ownerConnection !== connectionId) { + log.warn('close: not owner', { + connectionId, + sessionId: msg.sessionId, + owner: ownerConnection, + }); + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: false, + reason: 'Not session owner', + }); + return; + } + + closeSessionByUser(found.clientId); + log.info('session close initiated by user', { + connectionId, + sessionId: msg.sessionId, + clientId: found.clientId, + }); + + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: true, + }); + }, + ); +} + // ─── Dispatcher ────────────────────────────────────────────────────────────── /** @@ -708,6 +766,9 @@ export async function dispatchV2Message( case 'session_suspend': handleSessionSuspend(connectionId, msg, ctx); break; + case 'session_close': + handleSessionClose(connectionId, msg, ctx); + break; case 'send': handleSendV2(connectionId, transport, msg, ctx); break; From 0eabc653a6bf3477d511657888b1445a9e4fc3ac Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 13:54:42 +0100 Subject: [PATCH 45/45] fix(watch): use Tailscale-trusting URLSession in YapperClient + dynamic Team ID YapperClient health check and WebSocket used URLSession.shared, which rejects Tailscale TLS certs. AuthManager hardcoded the Team ID prefix. Co-Authored-By: Claude Opus 4.6 --- frontend/ios/App/App/Info.plist | 4 +++- .../Sources/MitzoShared/Auth/AuthManager.swift | 12 ++++++++++-- .../MitzoShared/Networking/YapperClient.swift | 5 ++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/ios/App/App/Info.plist b/frontend/ios/App/App/Info.plist index b4014b18..837390e0 100644 --- a/frontend/ios/App/App/Info.plist +++ b/frontend/ios/App/App/Info.plist @@ -2,7 +2,9 @@ - CAPACITOR_DEBUG + AppIdentifierPrefix + $(AppIdentifierPrefix) + CAPACITOR_DEBUG $(CAPACITOR_DEBUG) CFBundleDevelopmentRegion en diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift index 702858b3..3c6db458 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift @@ -13,8 +13,16 @@ public actor AuthManager { private let keychainService = "com.mitzo.app" private let keychainAccount = "jwt" - // Team ID prefix for shared Keychain access group (iOS + watchOS) - private let accessGroup = "Y4QGXHYSY3.com.mitzo.app" + // Shared Keychain access group (iOS + watchOS). + // AppIdentifierPrefix is injected by Xcode at build time — no hardcoded Team ID. + private let accessGroup: String = { + if let prefix = Bundle.main.infoDictionary?["AppIdentifierPrefix"] as? String { + return "\(prefix)com.mitzo.app" + } + // Fallback: read from entitlements at runtime isn't possible, + // so use the known group directly. This only fires in unit tests. + return "Y4QGXHYSY3.com.mitzo.app" + }() private var cachedToken: String? diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift index 61a04a34..78b42dda 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift @@ -27,7 +27,7 @@ public actor YapperClient { public func checkHealth() async throws -> Bool { let healthURL = baseURL.appendingPathComponent("/health") - let (data, _) = try await URLSession.shared.data(from: healthURL) + let (data, _) = try await tailscaleURLSession.data(from: healthURL) struct HealthResponse: Decodable { let status: String @@ -57,8 +57,7 @@ public actor YapperClient { throw YapperError.connectionFailed } - let session = URLSession(configuration: .default) - wsTask = session.webSocketTask(with: wsURL) + wsTask = tailscaleURLSession.webSocketTask(with: wsURL) wsTask?.resume() // Send format frame