From 7b87438911329206b1bc0a7a43da33569264a504 Mon Sep 17 00:00:00 2001 From: dimakis Date: Wed, 1 Apr 2026 02:26:12 +0100 Subject: [PATCH 1/4] feat: add SessionRegistry and extract summarizeToolInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD: tests written first, then implementation. SessionRegistry decouples session lifecycle from WebSocket connection lifecycle — sessions survive brief disconnects via detach/reattach with a configurable TTL. Made-with: Cursor --- server/__tests__/session-registry.test.ts | 323 ++++++++++++++++++++++ server/__tests__/tool-summary.test.ts | 84 ++++++ server/session-registry.ts | 146 ++++++++++ server/tool-summary.ts | 23 ++ 4 files changed, 576 insertions(+) create mode 100644 server/__tests__/session-registry.test.ts create mode 100644 server/__tests__/tool-summary.test.ts create mode 100644 server/session-registry.ts create mode 100644 server/tool-summary.ts diff --git a/server/__tests__/session-registry.test.ts b/server/__tests__/session-registry.test.ts new file mode 100644 index 0000000..0577551 --- /dev/null +++ b/server/__tests__/session-registry.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { SessionRegistry, DETACHED_TTL_MS } from '../session-registry.js'; + +describe('SessionRegistry', () => { + let registry: SessionRegistry; + + beforeEach(() => { + registry = new SessionRegistry(); + }); + + afterEach(() => { + registry.dispose(); + }); + + describe('register', () => { + it('registers a session and makes it retrievable by clientId', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + const fakeAbort = new AbortController(); + registry.register('client-1', { + ws: fakeWs, + abortController: fakeAbort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + expect(registry.get('client-1')).toBeDefined(); + expect(registry.get('client-1')!.ws).toBe(fakeWs); + }); + + it('marks session as attached on registration', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + expect(registry.isAttached('client-1')).toBe(true); + }); + + it('isActive returns true for registered session', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + expect(registry.isActive('client-1')).toBe(true); + }); + + it('isActive returns false for unknown clientId', () => { + expect(registry.isActive('nonexistent')).toBe(false); + }); + }); + + describe('detach', () => { + it('detaches a session without aborting it', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + const abort = new AbortController(); + registry.register('client-1', { + ws: fakeWs, + abortController: abort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.detach('client-1'); + + expect(registry.isAttached('client-1')).toBe(false); + expect(registry.isActive('client-1')).toBe(true); + expect(abort.signal.aborted).toBe(false); + }); + + it('stores the SDK sessionId when detaching', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + sessionId: 'sdk-session-abc', + }); + + registry.detach('client-1'); + const session = registry.get('client-1'); + expect(session!.sessionId).toBe('sdk-session-abc'); + }); + + it('is a no-op for unknown clientId', () => { + expect(() => registry.detach('nonexistent')).not.toThrow(); + }); + }); + + describe('reattach', () => { + it('reattaches a new WebSocket to a detached session', () => { + const oldWs = { readyState: 1, OPEN: 1 } as any; + const newWs = { readyState: 1, OPEN: 1 } as any; + + registry.register('client-1', { + ws: oldWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + sessionId: 'sdk-session-abc', + }); + + registry.detach('client-1'); + const reattached = registry.reattach('client-1', newWs); + + expect(reattached).toBe(true); + expect(registry.isAttached('client-1')).toBe(true); + expect(registry.get('client-1')!.ws).toBe(newWs); + }); + + it('returns false for unknown clientId', () => { + const ws = { readyState: 1, OPEN: 1 } as any; + expect(registry.reattach('nonexistent', ws)).toBe(false); + }); + + it('works on already-attached session (WS swap)', () => { + const ws1 = { readyState: 1, OPEN: 1 } as any; + const ws2 = { readyState: 1, OPEN: 1 } as any; + + registry.register('client-1', { + ws: ws1, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + const reattached = registry.reattach('client-1', ws2); + expect(reattached).toBe(true); + expect(registry.get('client-1')!.ws).toBe(ws2); + }); + + it('cancels the detach timeout when reattaching', () => { + vi.useFakeTimers(); + + const oldWs = { readyState: 1, OPEN: 1 } as any; + const newWs = { readyState: 1, OPEN: 1 } as any; + const abort = new AbortController(); + + registry.register('client-1', { + ws: oldWs, + abortController: abort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.detach('client-1'); + + // Reattach before timeout fires + registry.reattach('client-1', newWs); + + // Advance past the TTL — session should still be alive + vi.advanceTimersByTime(DETACHED_TTL_MS + 1000); + + expect(registry.isActive('client-1')).toBe(true); + expect(abort.signal.aborted).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('findBySessionId', () => { + it('finds a session by its SDK session ID', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + sessionId: 'sdk-123', + }); + + const result = registry.findBySessionId('sdk-123'); + expect(result).not.toBeNull(); + expect(result!.clientId).toBe('client-1'); + expect(result!.session.sessionId).toBe('sdk-123'); + }); + + it('returns null for unknown SDK session ID', () => { + expect(registry.findBySessionId('nonexistent')).toBeNull(); + }); + }); + + describe('abort', () => { + it('aborts the session and removes it from the registry', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + const abort = new AbortController(); + + registry.register('client-1', { + ws: fakeWs, + abortController: abort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.abort('client-1'); + + expect(abort.signal.aborted).toBe(true); + expect(registry.isActive('client-1')).toBe(false); + expect(registry.get('client-1')).toBeUndefined(); + }); + + it('is safe to call for unknown clientId', () => { + expect(() => registry.abort('nonexistent')).not.toThrow(); + }); + }); + + describe('remove', () => { + it('removes session without aborting', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + const abort = new AbortController(); + + registry.register('client-1', { + ws: fakeWs, + abortController: abort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.remove('client-1'); + + expect(abort.signal.aborted).toBe(false); + expect(registry.isActive('client-1')).toBe(false); + }); + }); + + describe('detach timeout', () => { + it('aborts the session after DETACHED_TTL_MS if not reattached', () => { + vi.useFakeTimers(); + + const fakeWs = { readyState: 1, OPEN: 1 } as any; + const abort = new AbortController(); + + registry.register('client-1', { + ws: fakeWs, + abortController: abort, + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.detach('client-1'); + + // Not yet expired + vi.advanceTimersByTime(DETACHED_TTL_MS - 1000); + expect(registry.isActive('client-1')).toBe(true); + expect(abort.signal.aborted).toBe(false); + + // Now expired + vi.advanceTimersByTime(2000); + expect(registry.isActive('client-1')).toBe(false); + expect(abort.signal.aborted).toBe(true); + + vi.useRealTimers(); + }); + }); + + describe('setSessionId', () => { + it('sets the SDK session ID on a registered session', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.setSessionId('client-1', 'sdk-456'); + expect(registry.get('client-1')!.sessionId).toBe('sdk-456'); + }); + + it('is a no-op for unknown clientId', () => { + expect(() => registry.setSessionId('nonexistent', 'sdk-456')).not.toThrow(); + }); + }); + + describe('setMode', () => { + it('updates the mode on a registered session', () => { + const fakeWs = { readyState: 1, OPEN: 1 } as any; + registry.register('client-1', { + ws: fakeWs, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + registry.setMode('client-1', 'auto'); + expect(registry.get('client-1')!.mode).toBe('auto'); + }); + }); + + describe('dispose', () => { + it('clears all detach timers and aborts all sessions', () => { + const abort1 = new AbortController(); + const abort2 = new AbortController(); + + registry.register('client-1', { + ws: { readyState: 1, OPEN: 1 } as any, + abortController: abort1, + mode: 'agent', + sessionAllowList: new Set(), + }); + registry.register('client-2', { + ws: { readyState: 1, OPEN: 1 } as any, + abortController: abort2, + mode: 'ask', + sessionAllowList: new Set(), + }); + + registry.dispose(); + + expect(abort1.signal.aborted).toBe(true); + expect(abort2.signal.aborted).toBe(true); + expect(registry.isActive('client-1')).toBe(false); + expect(registry.isActive('client-2')).toBe(false); + }); + }); +}); diff --git a/server/__tests__/tool-summary.test.ts b/server/__tests__/tool-summary.test.ts new file mode 100644 index 0000000..61d5e83 --- /dev/null +++ b/server/__tests__/tool-summary.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeToolInput } from '../tool-summary.js'; + +describe('summarizeToolInput', () => { + it('summarizes Read tool with file path', () => { + expect(summarizeToolInput('Read', { path: '/home/user/file.ts' })).toBe('/home/user/file.ts'); + }); + + it('summarizes Write tool with path and content length', () => { + const result = summarizeToolInput('Write', { + path: '/tmp/out.txt', + contents: 'hello world', + }); + expect(result).toBe('/tmp/out.txt (11 chars)'); + }); + + it('summarizes Edit/StrReplace tool with path', () => { + expect(summarizeToolInput('Edit', { path: '/src/index.ts' })).toBe('/src/index.ts'); + expect(summarizeToolInput('StrReplace', { path: '/src/index.ts' })).toBe('/src/index.ts'); + }); + + it('summarizes Bash tool with truncated command', () => { + const short = summarizeToolInput('Bash', { command: 'ls -la' }); + expect(short).toBe('ls -la'); + + const long = summarizeToolInput('Bash', { command: 'x'.repeat(300) }); + expect(long.length).toBe(200); + }); + + it('summarizes Glob tool with pattern and directory', () => { + expect( + summarizeToolInput('Glob', { + glob_pattern: '**/*.ts', + target_directory: '/src', + }), + ).toBe('**/*.ts in /src'); + }); + + it('summarizes Glob tool with default directory', () => { + expect(summarizeToolInput('Glob', { glob_pattern: '*.js' })).toBe('*.js in workspace'); + }); + + it('summarizes Grep tool with pattern and path', () => { + expect( + summarizeToolInput('Grep', { + pattern: 'TODO', + path: '/src', + }), + ).toBe('/TODO/ in /src'); + }); + + it('summarizes WebSearch tool', () => { + expect(summarizeToolInput('WebSearch', { search_term: 'vitest mocking' })).toBe( + 'vitest mocking', + ); + }); + + it('summarizes WebFetch tool', () => { + expect(summarizeToolInput('WebFetch', { url: 'https://example.com' })).toBe( + 'https://example.com', + ); + }); + + it('falls back to JSON.stringify for unknown tools', () => { + const result = summarizeToolInput('CustomTool', { foo: 'bar', baz: 42 }); + expect(result).toContain('foo'); + expect(result).toContain('bar'); + }); + + it('truncates unknown tool output to 200 chars', () => { + const bigInput: Record = {}; + for (let i = 0; i < 50; i++) { + bigInput[`key_${i}`] = 'a'.repeat(20); + } + const result = summarizeToolInput('CustomTool', bigInput); + expect(result.length).toBeLessThanOrEqual(200); + }); + + it('handles missing fields gracefully', () => { + expect(summarizeToolInput('Read', {})).toBe(''); + expect(summarizeToolInput('Write', {})).toBe(' (0 chars)'); + expect(summarizeToolInput('Bash', {})).toBe(''); + }); +}); diff --git a/server/session-registry.ts b/server/session-registry.ts new file mode 100644 index 0000000..f5b5bb2 --- /dev/null +++ b/server/session-registry.ts @@ -0,0 +1,146 @@ +import type { WebSocket } from 'ws'; + +export type MitzoMode = 'ask' | 'agent' | 'auto'; + +export interface ManagedSession { + ws: WebSocket; + abortController: AbortController; + sessionId?: string; + sessionAllowList: Set; + mode: MitzoMode; + worktreePath?: string; + queryInstance?: any; +} + +/** How long a detached session stays alive waiting for reattach (ms). */ +export const DETACHED_TTL_MS = 120_000; // 2 minutes + +export class SessionRegistry { + private sessions = new Map(); + private attached = new Set(); + private detachTimers = new Map>(); + + register( + clientId: string, + init: Omit & { sessionId?: string }, + ): void { + this.sessions.set(clientId, { ...init }); + this.attached.add(clientId); + } + + get(clientId: string): ManagedSession | undefined { + return this.sessions.get(clientId); + } + + isActive(clientId: string): boolean { + return this.sessions.has(clientId); + } + + isAttached(clientId: string): boolean { + return this.attached.has(clientId); + } + + /** + * Detach the WebSocket from a session without killing the SDK query. + * Starts a TTL timer — if no reattach arrives, the session is aborted. + */ + detach(clientId: string): void { + const session = this.sessions.get(clientId); + if (!session) return; + + this.attached.delete(clientId); + + this.clearDetachTimer(clientId); + + const timer = setTimeout(() => { + this.detachTimers.delete(clientId); + if (this.sessions.has(clientId) && !this.attached.has(clientId)) { + console.log(`[session-registry] detach TTL expired for ${clientId}, aborting`); + this.abort(clientId); + } + }, DETACHED_TTL_MS); + + this.detachTimers.set(clientId, timer); + } + + /** + * Reattach a new WebSocket to an existing session. + * Returns true if the session was found and reattached. + */ + reattach(clientId: string, ws: WebSocket): boolean { + const session = this.sessions.get(clientId); + if (!session) return false; + + session.ws = ws; + this.attached.add(clientId); + this.clearDetachTimer(clientId); + return true; + } + + /** + * Find a session by its SDK session ID (for reconnection by session ID). + */ + findBySessionId(sessionId: string): { clientId: string; session: ManagedSession } | null { + for (const [clientId, session] of this.sessions) { + if (session.sessionId === sessionId) { + return { clientId, session }; + } + } + return null; + } + + setSessionId(clientId: string, sessionId: string): void { + const session = this.sessions.get(clientId); + if (session) session.sessionId = sessionId; + } + + setMode(clientId: string, mode: MitzoMode): void { + const session = this.sessions.get(clientId); + if (session) session.mode = mode; + } + + /** + * Abort the SDK query and remove the session entirely. + */ + abort(clientId: string): void { + const session = this.sessions.get(clientId); + if (!session) return; + + this.clearDetachTimer(clientId); + session.abortController.abort(); + this.sessions.delete(clientId); + this.attached.delete(clientId); + } + + /** + * Remove a session from the registry without aborting. + * Used when the SDK query finishes naturally. + */ + remove(clientId: string): void { + this.clearDetachTimer(clientId); + this.sessions.delete(clientId); + this.attached.delete(clientId); + } + + /** + * Clean up all sessions and timers. Used for graceful shutdown. + */ + dispose(): void { + for (const timer of this.detachTimers.values()) { + clearTimeout(timer); + } + this.detachTimers.clear(); + + for (const [clientId] of this.sessions) { + this.abort(clientId); + } + } + + private clearDetachTimer(clientId: string): void { + const existing = this.detachTimers.get(clientId); + if (existing) { + clearTimeout(existing); + this.detachTimers.delete(clientId); + } + } +} diff --git a/server/tool-summary.ts b/server/tool-summary.ts new file mode 100644 index 0000000..63a694e --- /dev/null +++ b/server/tool-summary.ts @@ -0,0 +1,23 @@ +export function summarizeToolInput(toolName: string, input: Record): string { + switch (toolName) { + case 'Read': + return `${input.path || ''}`; + case 'Write': + return `${input.path || ''} (${String(input.contents || '').length} chars)`; + case 'Edit': + case 'StrReplace': + return `${input.path || ''}`; + case 'Bash': + return `${String(input.command || '').slice(0, 200)}`; + case 'Glob': + return `${input.glob_pattern || ''} in ${input.target_directory || 'workspace'}`; + case 'Grep': + return `/${input.pattern || ''}/ in ${input.path || 'workspace'}`; + case 'WebSearch': + return `${input.search_term || ''}`; + case 'WebFetch': + return `${input.url || ''}`; + default: + return JSON.stringify(input).slice(0, 200); + } +} From b4cc088e80ac9123ba8500437625c1c2b47fcc24 Mon Sep 17 00:00:00 2001 From: dimakis Date: Wed, 1 Apr 2026 02:26:35 +0100 Subject: [PATCH 2/4] fix: survive WebSocket disconnects without killing sessions WS disconnect now detaches (not aborts) the session. The SDK query keeps running server-side. On reconnect, the frontend sends a reattach message with the previous clientId. If the session is still alive, the new WS is swapped in and streaming resumes. Detached sessions auto-abort after 2 minutes if no reattach arrives. Made-with: Cursor --- frontend/src/pages/ChatView.tsx | 346 ++++++++++++++++++++------------ server/chat.ts | 217 ++++++++++---------- server/index.ts | 107 +++++++++- 3 files changed, 434 insertions(+), 236 deletions(-) diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 3f61fd6..1b1b0a9 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -74,32 +74,54 @@ export function ChatView() { const scrollRef = useRef(null); const reconnectTimer = useRef | null>(null); const intentionalClose = useRef(false); + const lastPayload = useRef(null); + const serverClientId = useRef(null); + const wasRunning = useRef(false); + + const isNearBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight < 150; + }, []); const scrollToBottom = useCallback(() => { + if (!isNearBottom()) return; requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth', }); }); - }, []); + }, [isNearBottom]); - const finalizeStream = useCallback(() => { - if (!streamBuf.current) return; - const text = streamBuf.current; - streamBuf.current = ''; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === 'assistant' && last.streaming) { - return [...prev.slice(0, -1), { role: 'assistant', text }]; - } - return [...prev, { role: 'assistant', text }]; + const forceScrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: 'smooth', + }); }); }, []); - // Load existing session history + // Restore session history: try sessionStorage first, then server useEffect(() => { if (!sessionId) return; + + const cacheKey = `mitzo-chat-${sessionId}`; + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + try { + const restored = JSON.parse(cached) as Message[]; + if (restored.length > 0) { + setMessages(restored); + setTimeout(forceScrollToBottom, 100); + return; + } + } catch { + // Corrupted cache — fall through to server + } + } + fetch(`/api/sessions/${sessionId}/messages`) .then((r) => (r.ok ? r.json() : [])) .then( @@ -131,129 +153,190 @@ export function ChatView() { } if (loaded.length > 0) { setMessages(loaded); - setTimeout(scrollToBottom, 100); + setTimeout(forceScrollToBottom, 100); } }, ) .catch(() => {}); - }, [sessionId, scrollToBottom]); - - const connectWs = useCallback(() => { - const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - const ws = new WebSocket(`${proto}://${location.host}/ws/chat`); - wsRef.current = ws; - - ws.onopen = () => { - setConnected(true); - }; + }, [sessionId, forceScrollToBottom]); - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); + // Persist messages to sessionStorage on every update + useEffect(() => { + if (currentSessionId && messages.length > 0) { + sessionStorage.setItem(`mitzo-chat-${currentSessionId}`, JSON.stringify(messages)); + } + }, [messages, currentSessionId]); - switch (msg.type) { - case 'session_id': - setCurrentSessionId(msg.sessionId); - break; + useEffect(() => { + intentionalClose.current = false; - case 'text_delta': - streamBuf.current += msg.text; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === 'assistant' && last.streaming) { + function connectWs() { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + if (wsRef.current?.readyState === WebSocket.CONNECTING) return; + + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/ws/chat`); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + switch (msg.type) { + case 'client_id': + // Server assigned a new clientId for this WS. + // If we had an active session on a previous WS, try to reattach. + if (wasRunning.current && serverClientId.current) { + ws.send( + JSON.stringify({ + type: 'reattach', + clientId: serverClientId.current, + }), + ); + } + serverClientId.current = msg.clientId; + break; + + case 'reattached': + serverClientId.current = msg.clientId; + setRunning(true); + if (msg.sessionId) setCurrentSessionId(msg.sessionId); + break; + + case 'reattach_failed': + wasRunning.current = false; + setRunning(false); + break; + + case 'session_id': + setCurrentSessionId(msg.sessionId); + break; + + case 'text_delta': + streamBuf.current += msg.text; + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === 'assistant' && last.streaming) { + return [ + ...prev.slice(0, -1), + { role: 'assistant', text: streamBuf.current, streaming: true }, + ]; + } + return [...prev, { role: 'assistant', text: streamBuf.current, streaming: true }]; + }); + scrollToBottom(); + break; + + case 'text': + streamBuf.current = ''; + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === 'assistant' && last.streaming) { + return [...prev.slice(0, -1), { role: 'assistant', text: msg.text }]; + } + return [...prev, { role: 'assistant', text: msg.text }]; + }); + scrollToBottom(); + break; + + case 'tool_call': + streamBuf.current = ''; + setMessages((prev) => { + const newPrev = streamBuf.current ? [...prev] : prev; return [ - ...prev.slice(0, -1), - { role: 'assistant', text: streamBuf.current, streaming: true }, + ...newPrev, + { role: 'tool', toolName: msg.toolName, toolId: msg.toolId, toolInput: msg.input }, ]; + }); + scrollToBottom(); + break; + + case 'tool_result': + setMessages((prev) => + prev.map((m) => (m.toolId === msg.toolId ? { ...m, toolResult: msg.result } : m)), + ); + scrollToBottom(); + break; + + case 'permission_request': + setPermission({ + permId: msg.permId, + toolName: msg.toolName, + toolInput: msg.toolInput, + }); + break; + + case 'permission_timeout': + setPermission((prev) => (prev?.permId === msg.permId ? null : prev)); + break; + + case 'done': + if (streamBuf.current) { + const text = streamBuf.current; + streamBuf.current = ''; + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === 'assistant' && last.streaming) { + return [...prev.slice(0, -1), { role: 'assistant', text }]; + } + return [...prev, { role: 'assistant', text }]; + }); } - return [...prev, { role: 'assistant', text: streamBuf.current, streaming: true }]; - }); - scrollToBottom(); - break; - - case 'text': - streamBuf.current = ''; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === 'assistant' && last.streaming) { - return [...prev.slice(0, -1), { role: 'assistant', text: msg.text }]; + setRunning(false); + wasRunning.current = false; + if (msg.sessionId) setCurrentSessionId(msg.sessionId); + break; + + case 'error': + streamBuf.current = ''; + setRunning(false); + wasRunning.current = false; + if (msg.error?.includes('No conversation found')) { + setCurrentSessionId(undefined); + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + text: 'Session expired. Send your message again to start fresh.', + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { role: 'assistant', text: `**Error:** ${msg.error}` }, + ]); } - return [...prev, { role: 'assistant', text: msg.text }]; - }); - scrollToBottom(); - break; - - case 'tool_call': - finalizeStream(); - setMessages((prev) => [ - ...prev, - { - role: 'tool', - toolName: msg.toolName, - toolId: msg.toolId, - toolInput: msg.input, - }, - ]); - scrollToBottom(); - break; - - case 'tool_result': - setMessages((prev) => - prev.map((m) => (m.toolId === msg.toolId ? { ...m, toolResult: msg.result } : m)), - ); - scrollToBottom(); - break; - - case 'permission_request': - setPermission({ - permId: msg.permId, - toolName: msg.toolName, - toolInput: msg.toolInput, - }); - break; - - case 'permission_timeout': - if (permission?.permId === msg.permId) { - setPermission(null); - } - break; - - case 'done': - finalizeStream(); - setRunning(false); - if (msg.sessionId) setCurrentSessionId(msg.sessionId); - break; - - case 'error': - finalizeStream(); - setRunning(false); - setMessages((prev) => [...prev, { role: 'assistant', text: `**Error:** ${msg.error}` }]); - scrollToBottom(); - break; - } - }; - - ws.onclose = () => { - setConnected(false); - setRunning(false); - wsRef.current = null; - - if (!intentionalClose.current) { - const delay = Math.min(2000 + Math.random() * 1000, 5000); - reconnectTimer.current = setTimeout(connectWs, delay); - } - }; - - ws.onerror = () => { - ws.close(); - }; - }, [finalizeStream, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps + scrollToBottom(); + break; + } + }; + + ws.onclose = () => { + setConnected(false); + // Don't setRunning(false) — session may still be alive server-side + wsRef.current = null; + + if (!intentionalClose.current) { + const delay = 2000 + Math.random() * 2000; + reconnectTimer.current = setTimeout(connectWs, delay); + } + }; + + ws.onerror = () => { + // onclose will fire after this + }; + } - useEffect(() => { - intentionalClose.current = false; - connectWs(); + const initTimer = setTimeout(connectWs, 100); const handleVisibility = () => { - if (document.visibilityState === 'visible' && !wsRef.current) { + if ( + document.visibilityState === 'visible' && + (!wsRef.current || wsRef.current.readyState > WebSocket.OPEN) + ) { connectWs(); } }; @@ -261,11 +344,12 @@ export function ChatView() { return () => { intentionalClose.current = true; + clearTimeout(initTimer); if (reconnectTimer.current) clearTimeout(reconnectTimer.current); wsRef.current?.close(); document.removeEventListener('visibilitychange', handleVisibility); }; - }, [connectWs]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const initialPrompt = searchParams.get('prompt') || undefined; @@ -274,15 +358,15 @@ export function ChatView() { if (!ws || ws.readyState !== WebSocket.OPEN) { setMessages((prev) => [ ...prev, - { role: 'assistant', text: '**Connection lost.** Reconnecting...' }, + { role: 'assistant', text: '**Connection lost.** Reconnecting — try again in a moment.' }, ]); - connectWs(); return; } const previews = images?.map((img) => img.preview); setMessages((prev) => [...prev, { role: 'user', text, images: previews }]); setRunning(true); + wasRunning.current = true; streamBuf.current = ''; const payload: Record = { type: 'send', prompt: text, model, mode }; @@ -297,12 +381,15 @@ export function ChatView() { const extraTools = searchParams.get('extraTools'); if (extraTools) payload.extraTools = extraTools; - ws.send(JSON.stringify(payload)); - scrollToBottom(); + const payloadStr = JSON.stringify(payload); + lastPayload.current = payloadStr; + ws.send(payloadStr); + forceScrollToBottom(); } function handleStop() { wsRef.current?.send(JSON.stringify({ type: 'stop' })); + wasRunning.current = false; } function handlePermission( @@ -331,7 +418,12 @@ export function ChatView() { - {!connected && ( + {!connected && running && ( + + ! + + )} + {!connected && !running && ( ! diff --git a/server/chat.ts b/server/chat.ts index a3c86d2..055f57e 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1,33 +1,26 @@ import { query, listSessions, getSessionMessages } from '@anthropic-ai/claude-agent-sdk'; import type { WebSocket } from 'ws'; -import { writeFileSync, mkdirSync } from 'fs'; +import { writeFileSync, mkdirSync, readdirSync } from 'fs'; import { join } from 'path'; +import { homedir } from 'os'; import { registerPending, resolvePending, removePending, hasPending } from './permissions.js'; import { sendPermissionNotification, isConfigured as ntfyConfigured } from './notify.js'; -import { createWorktree, removeWorktree } from './worktree.js'; +import { createWorktree } from './worktree.js'; +import { SessionRegistry, type MitzoMode } from './session-registry.js'; +import { summarizeToolInput } from './tool-summary.js'; + +export type { MitzoMode } from './session-registry.js'; export const BASE_REPO = process.env.REPO_PATH || ''; const WORKTREE_ENABLED = process.env.WORKTREE_ENABLED !== 'false'; -export type MitzoMode = 'ask' | 'agent' | 'auto'; - const MODE_TO_SDK: Record = { ask: 'plan', agent: 'default', auto: 'bypassPermissions', }; -interface ActiveSession { - queryInstance: any; - abortController: AbortController; - sessionId?: string; - ws: WebSocket; - sessionAllowList: Set; - mode: MitzoMode; - worktreePath?: string; -} - -const activeSessions = new Map(); +export const registry = new SessionRegistry(); const VENV_PATHS = [ `${BASE_REPO}/jira_process/.venv/bin`, @@ -56,6 +49,12 @@ export const AVAILABLE_MODELS = [ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest' }, ]; +function send(ws: WebSocket, data: unknown) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(data)); + } +} + export async function startChat( ws: WebSocket, clientId: string, @@ -81,8 +80,9 @@ export async function startChat( WORKTREE_ENABLED && options.worktree !== false && !options.cwd && !options.resume && BASE_REPO; if (useWorktree) { + const wtId = `wt-${Date.now().toString(36)}`; try { - worktreePath = createWorktree(clientId, BASE_REPO); + worktreePath = createWorktree(wtId, BASE_REPO); cwd = worktreePath; send(ws, { type: 'worktree', path: worktreePath }); } catch (err: any) { @@ -94,7 +94,6 @@ export async function startChat( } } - // Save attached images to cwd and augment the prompt with file paths let fullPrompt = prompt; if (options.images?.length) { const imgDir = join(cwd, '.mitzo-images'); @@ -124,14 +123,13 @@ export async function startChat( const baseAllowed = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']; const extraTools = options.extraTools ? options.extraTools.split(',').map((t) => t.trim()) : []; - const session: ActiveSession = { - queryInstance: null, - abortController, + registry.register(clientId, { ws, - sessionAllowList, + abortController, mode, + sessionAllowList, worktreePath, - }; + }); const q = query({ prompt: fullPrompt, @@ -147,11 +145,14 @@ export async function startChat( ...(options.model ? { model: options.model } : {}), ...(options.resume ? { resume: options.resume } : {}), canUseTool: async (toolName: string, toolInput: Record, opts: any) => { + const session = registry.get(clientId); + if (!session) return { behavior: 'deny' as const, message: 'Session not found' }; + if (session.mode === 'auto') { return { behavior: 'allow' as const }; } - if (sessionAllowList.has(toolName)) { + if (session.sessionAllowList.has(toolName)) { return { behavior: 'allow' as const, decisionClassification: 'user_permanent' as const }; } @@ -162,14 +163,14 @@ export async function startChat( const wrappedResolve = (result: any) => { if (result.behavior === 'allow' && result.decisionClassification === 'user_permanent') { - sessionAllowList.add(toolName); + session.sessionAllowList.add(toolName); } resolve(result); }; registerPending(permId, toolName, wrappedResolve, opts?.suggestions); - send(ws, { + send(session.ws, { type: 'permission_request', permId, toolName, @@ -188,7 +189,7 @@ export async function startChat( if (hasPending(permId)) { removePending(permId); resolve({ behavior: 'deny' as const, message: 'Permission request timed out' }); - send(ws, { type: 'permission_timeout', permId }); + send(session.ws, { type: 'permission_timeout', permId }); } }, 120_000); }); @@ -196,8 +197,8 @@ export async function startChat( }, }); + const session = registry.get(clientId)!; session.queryInstance = q; - activeSessions.set(clientId, session); const messageHandler = (raw: any) => { try { @@ -205,8 +206,8 @@ export async function startChat( if (msg.type === 'permission_response' && msg.permId) { resolvePending(msg.permId, msg.decision || 'deny'); } else if (msg.type === 'set_mode' && msg.mode) { - session.mode = msg.mode; - send(ws, { type: 'mode_changed', mode: msg.mode }); + registry.setMode(clientId, msg.mode); + send(session.ws, { type: 'mode_changed', mode: msg.mode }); } } catch { // Malformed WS message — ignore @@ -216,15 +217,17 @@ export async function startChat( try { for await (const msg of q) { - if (ws.readyState !== ws.OPEN) break; + const currentSession = registry.get(clientId); + if (!currentSession) break; + const currentWs = currentSession.ws; if (msg.type === 'assistant') { if (msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { - send(ws, { type: 'text', text: block.text }); + send(currentWs, { type: 'text', text: block.text }); } else if (block.type === 'tool_use') { - send(ws, { + send(currentWs, { type: 'tool_call', toolName: block.name, toolId: block.id, @@ -233,19 +236,19 @@ export async function startChat( } } } - if (!session.sessionId && msg.session_id) { - session.sessionId = msg.session_id; - send(ws, { type: 'session_id', sessionId: msg.session_id }); + if (!currentSession.sessionId && msg.session_id) { + registry.setSessionId(clientId, msg.session_id); + send(currentWs, { type: 'session_id', sessionId: msg.session_id }); } } else if (msg.type === 'result') { if (msg.session_id) { - send(ws, { type: 'session_id', sessionId: msg.session_id }); + send(currentWs, { type: 'session_id', sessionId: msg.session_id }); } - send(ws, { type: 'done', sessionId: msg.session_id }); + send(currentWs, { type: 'done', sessionId: msg.session_id }); } else if (msg.type === 'stream_event') { const evt = msg.event; if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { - send(ws, { type: 'text_delta', text: evt.delta.text }); + send(currentWs, { type: 'text_delta', text: evt.delta.text }); } } else if (msg.type === 'user' && msg.tool_use_result !== undefined) { const content = (msg.message as any)?.content; @@ -259,7 +262,7 @@ export async function startChat( if (c.type === 'text') resultText += c.text; } } - send(ws, { + send(currentWs, { type: 'tool_result', toolId: block.tool_use_id || '', result: resultText.slice(0, 2000), @@ -270,61 +273,99 @@ export async function startChat( } } } catch (err: any) { - if (!abortController.signal.aborted) { - send(ws, { type: 'error', error: err.message || 'Unknown error' }); + const currentSession = registry.get(clientId); + if (currentSession && !abortController.signal.aborted) { + send(currentSession.ws, { type: 'error', error: err.message || 'Unknown error' }); } } finally { ws.removeListener('message', messageHandler); - activeSessions.delete(clientId); - if (session.worktreePath) { - try { - removeWorktree(clientId, BASE_REPO); - } catch { - // Best-effort cleanup + const finalSession = registry.get(clientId); + if (finalSession) { + const finalWs = finalSession.ws; + registry.remove(clientId); + if (finalWs.readyState === finalWs.OPEN) { + send(finalWs, { type: 'done', sessionId: finalSession.sessionId }); } } - if (ws.readyState === ws.OPEN) { - send(ws, { type: 'done', sessionId: session.sessionId }); - } } } export function stopChat(clientId: string) { - const session = activeSessions.get(clientId); - if (session) { - session.abortController.abort(); - activeSessions.delete(clientId); - if (session.worktreePath) { - try { - removeWorktree(clientId, BASE_REPO); - } catch { - // Best-effort cleanup - } - } - } + registry.abort(clientId); +} + +export function detachChat(clientId: string) { + registry.detach(clientId); +} + +export function reattachChat(clientId: string, ws: WebSocket): boolean { + return registry.reattach(clientId, ws); } export function isActive(clientId: string): boolean { - return activeSessions.has(clientId); + return registry.isActive(clientId); } -export async function getSessions() { +function getSessionDirs(): string[] { + const dirs = [BASE_REPO]; + const sessionsDir = `${BASE_REPO}-sessions`; try { - const sessions = await listSessions({ dir: BASE_REPO, limit: 20 }); - return sessions.map((s) => ({ - id: s.sessionId, - summary: s.summary, - lastModified: s.lastModified, - branch: s.gitBranch, - })); + const entries = readdirSync(sessionsDir); + for (const e of entries) { + if (e.startsWith('session-')) dirs.push(join(sessionsDir, e)); + } } catch { - return []; + // No sessions dir yet } + const claudeProjects = join(homedir(), '.claude', 'projects'); + const prefix = BASE_REPO.replace(/\//g, '-').replace(/^-/, '-'); + const sessionsPrefix = `${prefix}-sessions-session-`; + try { + for (const entry of readdirSync(claudeProjects)) { + if (entry.startsWith(sessionsPrefix)) { + const originalPath = entry.replace(/^-/, '/').replace(/-/g, '/'); + if (!dirs.includes(originalPath)) dirs.push(originalPath); + } + } + } catch { + // No claude projects dir + } + return dirs; +} + +export async function getSessions() { + const allSessions: Array<{ id: string; summary: string; lastModified: number; branch?: string }> = + []; + for (const dir of getSessionDirs()) { + try { + const sessions = await listSessions({ dir, limit: 20 }); + for (const s of sessions) { + allSessions.push({ + id: s.sessionId, + summary: s.summary, + lastModified: s.lastModified, + branch: s.gitBranch, + }); + } + } catch { + // Dir might not exist — fine + } + } + allSessions.sort((a, b) => b.lastModified - a.lastModified); + return allSessions.slice(0, 20); } export async function getMessages(sessionId: string) { + let messages: any[] = []; + for (const dir of getSessionDirs()) { + try { + messages = await getSessionMessages(sessionId, { dir, limit: 100 }); + if (messages.length > 0) break; + } catch { + // Try next dir + } + } try { - const messages = await getSessionMessages(sessionId, { dir: BASE_REPO, limit: 100 }); return messages .map((m) => { const content = (m.message as any)?.content; @@ -366,33 +407,3 @@ export async function getMessages(sessionId: string) { return []; } } - -function send(ws: WebSocket, data: unknown) { - if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify(data)); - } -} - -function summarizeToolInput(toolName: string, input: Record): string { - switch (toolName) { - case 'Read': - return `${input.path || ''}`; - case 'Write': - return `${input.path || ''} (${String(input.contents || '').length} chars)`; - case 'Edit': - case 'StrReplace': - return `${input.path || ''}`; - case 'Bash': - return `${String(input.command || '').slice(0, 200)}`; - case 'Glob': - return `${input.glob_pattern || ''} in ${input.target_directory || 'workspace'}`; - case 'Grep': - return `/${input.pattern || ''}/ in ${input.path || 'workspace'}`; - case 'WebSearch': - return `${input.search_term || ''}`; - case 'WebFetch': - return `${input.url || ''}`; - default: - return JSON.stringify(input).slice(0, 200); - } -} diff --git a/server/index.ts b/server/index.ts index 1bc9606..254e304 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,19 +3,22 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import { createServer } from 'http'; import { WebSocketServer, WebSocket } from 'ws'; -import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { join, dirname, resolve, extname } from 'path'; import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { login, authMiddleware, verifyWsAuth, COOKIE_NAME, MAX_AGE_HOURS } from './auth.js'; import { startChat, stopChat, + detachChat, + reattachChat, isActive, getSessions, getMessages, AVAILABLE_MODELS, BASE_REPO, + registry, } from './chat.js'; import { cleanupStaleWorktrees, listWorktrees } from './worktree.js'; @@ -107,6 +110,65 @@ app.get('/api/worktrees', (_req, res) => { res.json(listWorktrees(BASE_REPO)); }); +// File viewer API — restricted to REPO_PATH and its worktrees +function isAllowedPath(filePath: string): boolean { + const resolved = resolve(filePath); + if (BASE_REPO && resolved.startsWith(resolve(BASE_REPO))) return true; + if (BASE_REPO && resolved.startsWith(resolve(`${BASE_REPO}-sessions`))) return true; + return false; +} + +app.get('/api/files', (req, res) => { + const dir = (req.query.dir as string) || BASE_REPO; + if (!dir || !isAllowedPath(dir)) { + res.status(403).json({ error: 'Path not allowed' }); + return; + } + if (!existsSync(dir)) { + res.status(404).json({ error: 'Directory not found' }); + return; + } + try { + const entries = readdirSync(dir) + .filter((name) => !name.startsWith('.')) + .map((name) => { + const full = join(dir, name); + try { + const stat = statSync(full); + return { name, isDir: stat.isDirectory() }; + } catch { + return { name, isDir: false }; + } + }) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + res.json({ dir, entries }); + } catch { + res.status(500).json({ error: 'Failed to read directory' }); + } +}); + +app.get('/api/files/read', (req, res) => { + const filePath = req.query.path as string; + if (!filePath || !isAllowedPath(filePath)) { + res.status(403).json({ error: 'Path not allowed' }); + return; + } + if (!existsSync(filePath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + try { + const content = readFileSync(filePath, 'utf-8'); + const ext = extname(filePath).toLowerCase(); + res.json({ path: filePath, content, ext }); + } catch { + res.status(500).json({ error: 'Failed to read file' }); + } +}); + // Static files const frontendDist = join(__dirname, '..', 'frontend', 'dist'); app.use(express.static(frontendDist)); @@ -143,10 +205,37 @@ server.on('upgrade', async (req, socket, head) => { }); function handleChatWs(ws: WebSocket, clientId: string) { + ws.send(JSON.stringify({ type: 'client_id', clientId })); + ws.on('message', async (raw) => { try { const msg = JSON.parse(raw.toString()); + if (msg.type === 'reattach' && msg.clientId) { + const ok = reattachChat(msg.clientId, ws); + if (ok) { + const session = registry.get(msg.clientId); + ws.send( + JSON.stringify({ + type: 'reattached', + clientId: msg.clientId, + sessionId: session?.sessionId, + running: true, + }), + ); + console.log('[ws] reattached:', msg.clientId, '(new ws:', clientId, ')'); + } else { + ws.send( + JSON.stringify({ + type: 'reattach_failed', + clientId: msg.clientId, + reason: 'Session not found or already finished', + }), + ); + } + return; + } + if (msg.type === 'send' && msg.prompt) { if (isActive(clientId)) { ws.send( @@ -169,15 +258,21 @@ function handleChatWs(ws: WebSocket, clientId: string) { } else if (msg.type === 'stop') { stopChat(clientId); } - // permission_response is handled inside startChat's message handler } catch (err: any) { ws.send(JSON.stringify({ type: 'error', error: err.message })); } }); - ws.on('close', () => { - console.log('[ws] chat disconnected:', clientId); - stopChat(clientId); + ws.on('close', (code, reason) => { + console.log('[ws] chat disconnected:', clientId, 'code:', code, 'reason:', reason?.toString()); + if (isActive(clientId)) { + detachChat(clientId); + console.log('[ws] session detached (surviving):', clientId); + } + }); + + ws.on('error', (err) => { + console.error('[ws] error:', clientId, err.message); }); } From 3853b37b02df6c315445c55010afe4b22e1563e7 Mon Sep 17 00:00:00 2001 From: dimakis Date: Wed, 1 Apr 2026 07:39:13 +0100 Subject: [PATCH 3/4] fix: deduplicate sessions list and add dismiss/clear controls getSessions() now deduplicates by session ID (keeping most recent), which eliminates the repeated entries caused by querying multiple worktree dirs. Added hide/clear endpoints (DELETE /api/sessions/:id, DELETE /api/sessions). Frontend: swipe-to-dismiss on individual sessions (mobile touch), "Clear" button in the section header. Made-with: Cursor --- frontend/src/pages/SessionList.tsx | 105 +++++++++++++++++++++++++---- frontend/src/styles/global.css | 24 ++++++- server/chat.ts | 37 +++++++--- server/index.ts | 12 ++++ 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/frontend/src/pages/SessionList.tsx b/frontend/src/pages/SessionList.tsx index 693bb3c..dfb404b 100644 --- a/frontend/src/pages/SessionList.tsx +++ b/frontend/src/pages/SessionList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; interface Session { @@ -71,6 +71,76 @@ function formatRelativeTime(ts: number): string { return `${days}d ago`; } +function SwipeableSession({ + session, + onDismiss, + onClick, +}: { + session: Session; + onDismiss: (id: string) => void; + onClick: (id: string) => void; +}) { + const ref = useRef(null); + const startX = useRef(0); + const currentX = useRef(0); + const swiping = useRef(false); + + function handleTouchStart(e: React.TouchEvent) { + startX.current = e.touches[0].clientX; + currentX.current = startX.current; + swiping.current = true; + } + + function handleTouchMove(e: React.TouchEvent) { + if (!swiping.current || !ref.current) return; + currentX.current = e.touches[0].clientX; + const dx = currentX.current - startX.current; + if (dx < 0) { + ref.current.style.transform = `translateX(${dx}px)`; + ref.current.style.opacity = `${Math.max(0, 1 + dx / 200)}`; + } + } + + function handleTouchEnd() { + if (!swiping.current || !ref.current) return; + swiping.current = false; + const dx = currentX.current - startX.current; + if (dx < -100) { + ref.current.style.transition = 'transform 0.2s, opacity 0.2s'; + ref.current.style.transform = 'translateX(-100%)'; + ref.current.style.opacity = '0'; + setTimeout(() => onDismiss(session.id), 200); + } else { + ref.current.style.transition = 'transform 0.2s, opacity 0.2s'; + ref.current.style.transform = 'translateX(0)'; + ref.current.style.opacity = '1'; + setTimeout(() => { + if (ref.current) ref.current.style.transition = ''; + }, 200); + } + } + + return ( +
onClick(session.id)} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + > +
+
{session.summary || 'Untitled session'}
+
+ {formatRelativeTime(session.lastModified)} + {session.branch && {session.branch}} +
+
+ +
+ ); +} + export function SessionList() { const navigate = useNavigate(); const [sessions, setSessions] = useState([]); @@ -93,6 +163,16 @@ export function SessionList() { .finally(() => setLoading(false)); }, []); + function dismissSession(id: string) { + setSessions((prev) => prev.filter((s) => s.id !== id)); + fetch(`/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); + } + + function clearAll() { + setSessions([]); + fetch('/api/sessions', { method: 'DELETE' }).catch(() => {}); + } + function handleQuickAction(action: QuickAction) { const params = new URLSearchParams(); if (action.prompt) params.set('prompt', action.prompt); @@ -131,18 +211,19 @@ export function SessionList() { {!loading && sessions.length > 0 && (
-
Recent Sessions
- {sessions.map((s) => ( - +
+ {sessions.map((s) => ( + navigate(`/chat/${id}`)} + /> ))} )} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9806504..6c42ca6 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -215,13 +215,35 @@ textarea:focus { flex-direction: column; } +.session-list-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0 0.5rem; +} + .session-list-section-title { font-size: 0.75rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; - padding: 0.75rem 0 0.5rem; +} + +.session-list-clear { + font-size: 0.7rem; + padding: 0.25rem 0.6rem; + background: transparent; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: 4px; + font-weight: 500; +} + +.session-list-clear:hover { + color: var(--danger); + border-color: var(--danger); + background: rgba(244, 67, 54, 0.08); } .session-item { diff --git a/server/chat.ts b/server/chat.ts index 055f57e..50fb0fc 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -333,26 +333,43 @@ function getSessionDirs(): string[] { return dirs; } +const hiddenSessionIds = new Set(); + +export function hideSession(sessionId: string) { + hiddenSessionIds.add(sessionId); +} + +export function clearHiddenSessions() { + hiddenSessionIds.clear(); +} + export async function getSessions() { - const allSessions: Array<{ id: string; summary: string; lastModified: number; branch?: string }> = - []; + const seen = new Map< + string, + { id: string; summary: string; lastModified: number; branch?: string } + >(); for (const dir of getSessionDirs()) { try { const sessions = await listSessions({ dir, limit: 20 }); for (const s of sessions) { - allSessions.push({ - id: s.sessionId, - summary: s.summary, - lastModified: s.lastModified, - branch: s.gitBranch, - }); + if (hiddenSessionIds.has(s.sessionId)) continue; + const existing = seen.get(s.sessionId); + if (!existing || s.lastModified > existing.lastModified) { + seen.set(s.sessionId, { + id: s.sessionId, + summary: s.summary, + lastModified: s.lastModified, + branch: s.gitBranch, + }); + } } } catch { // Dir might not exist — fine } } - allSessions.sort((a, b) => b.lastModified - a.lastModified); - return allSessions.slice(0, 20); + const deduped = Array.from(seen.values()); + deduped.sort((a, b) => b.lastModified - a.lastModified); + return deduped.slice(0, 20); } export async function getMessages(sessionId: string) { diff --git a/server/index.ts b/server/index.ts index 254e304..044f9a9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,8 @@ import { isActive, getSessions, getMessages, + hideSession, + clearHiddenSessions, AVAILABLE_MODELS, BASE_REPO, registry, @@ -106,6 +108,16 @@ app.get('/api/sessions/:id/messages', async (req, res) => { res.json(await getMessages(req.params.id as string)); }); +app.delete('/api/sessions/:id', (req, res) => { + hideSession(req.params.id as string); + res.json({ ok: true }); +}); + +app.delete('/api/sessions', (_req, res) => { + clearHiddenSessions(); + res.json({ ok: true }); +}); + app.get('/api/worktrees', (_req, res) => { res.json(listWorktrees(BASE_REPO)); }); From 233cde63a7ca6fb88c714304caa443bdce4eb3c6 Mon Sep 17 00:00:00 2001 From: dimakis Date: Wed, 1 Apr 2026 07:47:11 +0100 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20code=20audit=20cleanup=20?= =?UTF-8?q?=E2=80=94=20extract=20types,=20utils,=20break=20up=20god=20func?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - Extract shared types (Message, Session, ImageAttachment, etc.) to types/chat.ts - Extract utilities: groupMessages, formatRelativeTime, resizeImage, truncate to lib/ - Components now import from types/ instead of coupling to ChatView page - Wrap handlePermission in useCallback (stabilizes PermissionBanner timer) - Guard JSON.parse in WS onmessage with try/catch - Replace Record with Record - Remove unused lastPayload ref Server: - Extract content-blocks.ts: parseContentBlocks + extractToolResultText (was duplicated between streaming loop and getMessages) - Break up startChat: extract resolveWorktree, stageImages, buildPermissionHandler - Remove dead buildNotificationHeaders export from notify.ts - Replace err: any with err: unknown + instanceof checks - Update chat.test.ts exports check, remove stale comment - Add content-blocks.test.ts (7 new tests) Total: 70 tests passing, 0 lint errors, tsc clean. Made-with: Cursor --- frontend/src/components/ChatInput.tsx | 43 +-- frontend/src/components/MessageBubble.tsx | 2 +- frontend/src/components/PermissionBanner.tsx | 5 +- frontend/src/components/ToolGroup.tsx | 2 +- frontend/src/components/ToolPill.tsx | 5 +- frontend/src/lib/formatTime.ts | 10 + frontend/src/lib/groupMessages.ts | 29 ++ frontend/src/lib/resizeImage.ts | 37 +++ frontend/src/lib/truncate.ts | 3 + frontend/src/pages/ChatView.tsx | 217 ++++++-------- frontend/src/pages/SessionList.tsx | 20 +- frontend/src/types/chat.ts | 33 +++ server/__tests__/chat.test.ts | 12 +- server/__tests__/content-blocks.test.ts | 77 +++++ server/__tests__/notify.test.ts | 14 +- server/chat.ts | 288 +++++++++---------- server/content-blocks.ts | 66 +++++ server/notify.ts | 13 +- 18 files changed, 494 insertions(+), 382 deletions(-) create mode 100644 frontend/src/lib/formatTime.ts create mode 100644 frontend/src/lib/groupMessages.ts create mode 100644 frontend/src/lib/resizeImage.ts create mode 100644 frontend/src/lib/truncate.ts create mode 100644 frontend/src/types/chat.ts create mode 100644 server/__tests__/content-blocks.test.ts create mode 100644 server/content-blocks.ts diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx index 79c6fdf..5a83d47 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/ChatInput.tsx @@ -6,12 +6,8 @@ import { type KeyboardEvent, type ChangeEvent, } from 'react'; - -export interface ImageAttachment { - data: string; - mediaType: string; - preview: string; -} +import type { ImageAttachment } from '../types/chat'; +import { resizeImage } from '../lib/resizeImage'; interface Props { onSend: (text: string, images?: ImageAttachment[]) => void; @@ -21,41 +17,6 @@ interface Props { } const MAX_IMAGES = 4; -const MAX_DIMENSION = 1600; - -function resizeImage(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const img = new Image(); - img.onload = () => { - let { width, height } = img; - if (width > MAX_DIMENSION || height > MAX_DIMENSION) { - const scale = MAX_DIMENSION / Math.max(width, height); - width = Math.round(width * scale); - height = Math.round(height * scale); - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) return reject(new Error('Canvas not supported')); - - ctx.drawImage(img, 0, 0, width, height); - const dataUrl = canvas.toDataURL(file.type || 'image/jpeg', 0.85); - const [header, data] = dataUrl.split(','); - const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - - resolve({ data, mediaType, preview: dataUrl }); - }; - img.onerror = () => reject(new Error('Failed to load image')); - img.src = reader.result as string; - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); -} export function ChatInput({ onSend, onStop, running, initialText }: Props) { const [text, setText] = useState(initialText || ''); diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index eb9ee61..2406141 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -1,6 +1,6 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import type { Message } from '../pages/ChatView'; +import type { Message } from '../types/chat'; interface Props { message: Message; diff --git a/frontend/src/components/PermissionBanner.tsx b/frontend/src/components/PermissionBanner.tsx index addb5bf..b83f9f6 100644 --- a/frontend/src/components/PermissionBanner.tsx +++ b/frontend/src/components/PermissionBanner.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { truncate } from '../lib/truncate'; interface Props { permId: string; @@ -38,9 +39,7 @@ export function PermissionBanner({ permId, toolName, toolInput, onRespond }: Pro
{toolName} -
-          {toolInput.length > 200 ? toolInput.slice(0, 200) + '...' : toolInput}
-        
+
{truncate(toolInput, 200)}
Auto-deny in {remaining}s
diff --git a/frontend/src/components/ToolGroup.tsx b/frontend/src/components/ToolGroup.tsx index 414429a..b198998 100644 --- a/frontend/src/components/ToolGroup.tsx +++ b/frontend/src/components/ToolGroup.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { ToolPill } from './ToolPill'; -import type { Message } from '../pages/ChatView'; +import type { Message } from '../types/chat'; interface Props { tools: Message[]; diff --git a/frontend/src/components/ToolPill.tsx b/frontend/src/components/ToolPill.tsx index dc3efc5..0664b23 100644 --- a/frontend/src/components/ToolPill.tsx +++ b/frontend/src/components/ToolPill.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import type { Message } from '../pages/ChatView'; +import type { Message } from '../types/chat'; +import { truncate } from '../lib/truncate'; interface Props { message: Message; @@ -9,7 +10,7 @@ export function ToolPill({ message }: Props) { const [expanded, setExpanded] = useState(false); const done = message.toolResult !== undefined; const input = message.toolInput || ''; - const truncatedInput = input.length > 60 ? input.slice(0, 60) + '...' : input; + const truncatedInput = truncate(input, 60); return (
diff --git a/frontend/src/lib/formatTime.ts b/frontend/src/lib/formatTime.ts new file mode 100644 index 0000000..4ab18b4 --- /dev/null +++ b/frontend/src/lib/formatTime.ts @@ -0,0 +1,10 @@ +export function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} diff --git a/frontend/src/lib/groupMessages.ts b/frontend/src/lib/groupMessages.ts new file mode 100644 index 0000000..66ad280 --- /dev/null +++ b/frontend/src/lib/groupMessages.ts @@ -0,0 +1,29 @@ +import type { Message, GroupedItem } from '../types/chat'; + +export function groupMessages(messages: Message[]): GroupedItem[] { + const result: GroupedItem[] = []; + let toolBuffer: Message[] = []; + + function flushTools() { + if (toolBuffer.length === 0) return; + if (toolBuffer.length >= 3) { + result.push({ type: 'tool-group', tools: toolBuffer }); + } else { + for (const t of toolBuffer) { + result.push({ type: 'message', message: t }); + } + } + toolBuffer = []; + } + + for (const msg of messages) { + if (msg.role === 'tool') { + toolBuffer.push(msg); + } else { + flushTools(); + result.push({ type: 'message', message: msg }); + } + } + flushTools(); + return result; +} diff --git a/frontend/src/lib/resizeImage.ts b/frontend/src/lib/resizeImage.ts new file mode 100644 index 0000000..17efae2 --- /dev/null +++ b/frontend/src/lib/resizeImage.ts @@ -0,0 +1,37 @@ +import type { ImageAttachment } from '../types/chat'; + +const MAX_DIMENSION = 1600; + +export function resizeImage(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + if (width > MAX_DIMENSION || height > MAX_DIMENSION) { + const scale = MAX_DIMENSION / Math.max(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Canvas not supported')); + + ctx.drawImage(img, 0, 0, width, height); + const dataUrl = canvas.toDataURL(file.type || 'image/jpeg', 0.85); + const [header, data] = dataUrl.split(','); + const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; + + resolve({ data, mediaType, preview: dataUrl }); + }; + img.onerror = () => reject(new Error('Failed to load image')); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); +} diff --git a/frontend/src/lib/truncate.ts b/frontend/src/lib/truncate.ts new file mode 100644 index 0000000..d6e9b52 --- /dev/null +++ b/frontend/src/lib/truncate.ts @@ -0,0 +1,3 @@ +export function truncate(str: string, max: number): string { + return str.length > max ? str.slice(0, max) + '...' : str; +} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 1b1b0a9..ea19113 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -4,54 +4,9 @@ import { MessageBubble } from '../components/MessageBubble'; import { ToolPill } from '../components/ToolPill'; import { ToolGroup } from '../components/ToolGroup'; import { PermissionBanner } from '../components/PermissionBanner'; -import { ChatInput, type ImageAttachment } from '../components/ChatInput'; - -export interface Message { - role: 'user' | 'assistant' | 'tool'; - text?: string; - images?: string[]; - toolName?: string; - toolId?: string; - toolInput?: string; - toolResult?: string; - streaming?: boolean; -} - -type GroupedItem = { type: 'message'; message: Message } | { type: 'tool-group'; tools: Message[] }; - -function groupMessages(messages: Message[]): GroupedItem[] { - const result: GroupedItem[] = []; - let toolBuffer: Message[] = []; - - function flushTools() { - if (toolBuffer.length === 0) return; - if (toolBuffer.length >= 3) { - result.push({ type: 'tool-group', tools: toolBuffer }); - } else { - for (const t of toolBuffer) { - result.push({ type: 'message', message: t }); - } - } - toolBuffer = []; - } - - for (const msg of messages) { - if (msg.role === 'tool') { - toolBuffer.push(msg); - } else { - flushTools(); - result.push({ type: 'message', message: msg }); - } - } - flushTools(); - return result; -} - -interface PermissionRequest { - permId: string; - toolName: string; - toolInput: string; -} +import { ChatInput } from '../components/ChatInput'; +import { groupMessages } from '../lib/groupMessages'; +import type { Message, PermissionRequest, ImageAttachment } from '../types/chat'; export function ChatView() { const { sessionId } = useParams<{ sessionId?: string }>(); @@ -74,7 +29,6 @@ export function ChatView() { const scrollRef = useRef(null); const reconnectTimer = useRef | null>(null); const intentionalClose = useRef(false); - const lastPayload = useRef(null); const serverClientId = useRef(null); const wasRunning = useRef(false); @@ -87,23 +41,16 @@ export function ChatView() { const scrollToBottom = useCallback(() => { if (!isNearBottom()) return; requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: 'smooth', - }); + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }); }, [isNearBottom]); const forceScrollToBottom = useCallback(() => { requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: 'smooth', - }); + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }); }, []); - // Restore session history: try sessionStorage first, then server useEffect(() => { if (!sessionId) return; @@ -125,7 +72,14 @@ export function ChatView() { fetch(`/api/sessions/${sessionId}/messages`) .then((r) => (r.ok ? r.json() : [])) .then( - (msgs: Array<{ role: string; text?: string; toolCalls?: any[]; toolResults?: any[] }>) => { + ( + msgs: Array<{ + role: string; + text?: string; + toolCalls?: Array<{ toolName: string; toolId: string; input: string }>; + toolResults?: Array<{ toolId: string; result: string }>; + }>, + ) => { const loaded: Message[] = []; for (const m of msgs) { if (m.text) { @@ -143,11 +97,7 @@ export function ChatView() { } if (m.toolResults) { for (const tr of m.toolResults) { - loaded.push({ - role: 'tool', - toolId: tr.toolId, - toolResult: tr.result, - }); + loaded.push({ role: 'tool', toolId: tr.toolId, toolResult: tr.result }); } } } @@ -160,7 +110,6 @@ export function ChatView() { .catch(() => {}); }, [sessionId, forceScrollToBottom]); - // Persist messages to sessionStorage on every update useEffect(() => { if (currentSessionId && messages.length > 0) { sessionStorage.setItem(`mitzo-chat-${currentSessionId}`, JSON.stringify(messages)); @@ -178,32 +127,28 @@ export function ChatView() { const ws = new WebSocket(`${proto}://${location.host}/ws/chat`); wsRef.current = ws; - ws.onopen = () => { - setConnected(true); - }; + ws.onopen = () => setConnected(true); ws.onmessage = (event) => { - const msg = JSON.parse(event.data); + let msg: Record; + try { + msg = JSON.parse(event.data as string); + } catch { + return; + } switch (msg.type) { case 'client_id': - // Server assigned a new clientId for this WS. - // If we had an active session on a previous WS, try to reattach. if (wasRunning.current && serverClientId.current) { - ws.send( - JSON.stringify({ - type: 'reattach', - clientId: serverClientId.current, - }), - ); + ws.send(JSON.stringify({ type: 'reattach', clientId: serverClientId.current })); } - serverClientId.current = msg.clientId; + serverClientId.current = msg.clientId as string; break; case 'reattached': - serverClientId.current = msg.clientId; + serverClientId.current = msg.clientId as string; setRunning(true); - if (msg.sessionId) setCurrentSessionId(msg.sessionId); + if (msg.sessionId) setCurrentSessionId(msg.sessionId as string); break; case 'reattach_failed': @@ -212,20 +157,23 @@ export function ChatView() { break; case 'session_id': - setCurrentSessionId(msg.sessionId); + setCurrentSessionId(msg.sessionId as string); break; case 'text_delta': - streamBuf.current += msg.text; + streamBuf.current += msg.text as string; setMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === 'assistant' && last.streaming) { return [ ...prev.slice(0, -1), - { role: 'assistant', text: streamBuf.current, streaming: true }, + { role: 'assistant' as const, text: streamBuf.current, streaming: true }, ]; } - return [...prev, { role: 'assistant', text: streamBuf.current, streaming: true }]; + return [ + ...prev, + { role: 'assistant' as const, text: streamBuf.current, streaming: true }, + ]; }); scrollToBottom(); break; @@ -235,37 +183,44 @@ export function ChatView() { setMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === 'assistant' && last.streaming) { - return [...prev.slice(0, -1), { role: 'assistant', text: msg.text }]; + return [ + ...prev.slice(0, -1), + { role: 'assistant' as const, text: msg.text as string }, + ]; } - return [...prev, { role: 'assistant', text: msg.text }]; + return [...prev, { role: 'assistant' as const, text: msg.text as string }]; }); scrollToBottom(); break; case 'tool_call': streamBuf.current = ''; - setMessages((prev) => { - const newPrev = streamBuf.current ? [...prev] : prev; - return [ - ...newPrev, - { role: 'tool', toolName: msg.toolName, toolId: msg.toolId, toolInput: msg.input }, - ]; - }); + setMessages((prev) => [ + ...prev, + { + role: 'tool' as const, + toolName: msg.toolName as string, + toolId: msg.toolId as string, + toolInput: msg.input as string, + }, + ]); scrollToBottom(); break; case 'tool_result': setMessages((prev) => - prev.map((m) => (m.toolId === msg.toolId ? { ...m, toolResult: msg.result } : m)), + prev.map((m) => + m.toolId === msg.toolId ? { ...m, toolResult: msg.result as string } : m, + ), ); scrollToBottom(); break; case 'permission_request': setPermission({ - permId: msg.permId, - toolName: msg.toolName, - toolInput: msg.toolInput, + permId: msg.permId as string, + toolName: msg.toolName as string, + toolInput: msg.toolInput as string, }); break; @@ -273,40 +228,41 @@ export function ChatView() { setPermission((prev) => (prev?.permId === msg.permId ? null : prev)); break; - case 'done': + case 'done': { if (streamBuf.current) { const text = streamBuf.current; streamBuf.current = ''; setMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === 'assistant' && last.streaming) { - return [...prev.slice(0, -1), { role: 'assistant', text }]; + return [...prev.slice(0, -1), { role: 'assistant' as const, text }]; } - return [...prev, { role: 'assistant', text }]; + return [...prev, { role: 'assistant' as const, text }]; }); } setRunning(false); wasRunning.current = false; - if (msg.sessionId) setCurrentSessionId(msg.sessionId); + if (msg.sessionId) setCurrentSessionId(msg.sessionId as string); break; + } case 'error': streamBuf.current = ''; setRunning(false); wasRunning.current = false; - if (msg.error?.includes('No conversation found')) { + if ((msg.error as string)?.includes('No conversation found')) { setCurrentSessionId(undefined); setMessages((prev) => [ ...prev, { - role: 'assistant', + role: 'assistant' as const, text: 'Session expired. Send your message again to start fresh.', }, ]); } else { setMessages((prev) => [ ...prev, - { role: 'assistant', text: `**Error:** ${msg.error}` }, + { role: 'assistant' as const, text: `**Error:** ${msg.error}` }, ]); } scrollToBottom(); @@ -316,18 +272,14 @@ export function ChatView() { ws.onclose = () => { setConnected(false); - // Don't setRunning(false) — session may still be alive server-side wsRef.current = null; - if (!intentionalClose.current) { const delay = 2000 + Math.random() * 2000; reconnectTimer.current = setTimeout(connectWs, delay); } }; - ws.onerror = () => { - // onclose will fire after this - }; + ws.onerror = () => {}; } const initTimer = setTimeout(connectWs, 100); @@ -358,7 +310,10 @@ export function ChatView() { if (!ws || ws.readyState !== WebSocket.OPEN) { setMessages((prev) => [ ...prev, - { role: 'assistant', text: '**Connection lost.** Reconnecting — try again in a moment.' }, + { + role: 'assistant', + text: '**Connection lost.** Reconnecting — try again in a moment.', + }, ]); return; } @@ -369,7 +324,7 @@ export function ChatView() { wasRunning.current = true; streamBuf.current = ''; - const payload: Record = { type: 'send', prompt: text, model, mode }; + const payload: Record = { type: 'send', prompt: text, model, mode }; if (currentSessionId) payload.resume = currentSessionId; if (images?.length) { payload.images = images.map((img) => ({ data: img.data, mediaType: img.mediaType })); @@ -377,31 +332,27 @@ export function ChatView() { const cwd = searchParams.get('cwd'); if (cwd) payload.cwd = cwd; - const extraTools = searchParams.get('extraTools'); if (extraTools) payload.extraTools = extraTools; - const payloadStr = JSON.stringify(payload); - lastPayload.current = payloadStr; - ws.send(payloadStr); + ws.send(JSON.stringify(payload)); forceScrollToBottom(); } - function handleStop() { + const handleStop = useCallback(() => { wsRef.current?.send(JSON.stringify({ type: 'stop' })); wasRunning.current = false; - } + }, []); - function handlePermission( - permId: string, - decision: 'once' | 'always' | 'deny', - toolName: string, - ) { - wsRef.current?.send( - JSON.stringify({ type: 'permission_response', permId, decision, toolName }), - ); - setPermission(null); - } + const handlePermission = useCallback( + (permId: string, decision: 'once' | 'always' | 'deny', toolName: string) => { + wsRef.current?.send( + JSON.stringify({ type: 'permission_response', permId, decision, toolName }), + ); + setPermission(null); + }, + [], + ); function handleModeChange(newMode: 'ask' | 'agent' | 'auto') { setMode(newMode); @@ -418,13 +369,11 @@ export function ChatView() { - {!connected && running && ( - - ! - - )} - {!connected && !running && ( - + {!connected && ( + ! )} diff --git a/frontend/src/pages/SessionList.tsx b/frontend/src/pages/SessionList.tsx index dfb404b..f9ca782 100644 --- a/frontend/src/pages/SessionList.tsx +++ b/frontend/src/pages/SessionList.tsx @@ -1,12 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; - -interface Session { - id: string; - summary: string; - lastModified: number; - branch?: string; -} +import type { Session } from '../types/chat'; +import { formatRelativeTime } from '../lib/formatTime'; interface QuickAction { label: string; @@ -60,17 +55,6 @@ function buildQuickActions(repoPath: string): QuickAction[] { return actions; } -function formatRelativeTime(ts: number): string { - const diff = Date.now() - ts; - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - function SwipeableSession({ session, onDismiss, diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts new file mode 100644 index 0000000..3342c81 --- /dev/null +++ b/frontend/src/types/chat.ts @@ -0,0 +1,33 @@ +export interface Message { + role: 'user' | 'assistant' | 'tool'; + text?: string; + images?: string[]; + toolName?: string; + toolId?: string; + toolInput?: string; + toolResult?: string; + streaming?: boolean; +} + +export type GroupedItem = + | { type: 'message'; message: Message } + | { type: 'tool-group'; tools: Message[] }; + +export interface PermissionRequest { + permId: string; + toolName: string; + toolInput: string; +} + +export interface ImageAttachment { + data: string; + mediaType: string; + preview: string; +} + +export interface Session { + id: string; + summary: string; + lastModified: number; + branch?: string; +} diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index 13d2d68..760a0a7 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -1,13 +1,5 @@ import { describe, it, expect } from 'vitest'; -// We can't easily test the full Agent SDK integration without mocking, -// but we can test the utility functions and message flow logic. -// The SDK itself is tested by Anthropic; we test our glue code. - -// Import the summarizeToolInput function by extracting it. -// Since it's not exported, we test it indirectly through the module behavior. -// For now, test the public API shape. - describe('chat module exports', () => { it('exports expected functions', async () => { const chat = await import('../chat.js'); @@ -16,6 +8,10 @@ describe('chat module exports', () => { expect(typeof chat.isActive).toBe('function'); expect(typeof chat.getSessions).toBe('function'); expect(typeof chat.getMessages).toBe('function'); + expect(typeof chat.detachChat).toBe('function'); + expect(typeof chat.reattachChat).toBe('function'); + expect(typeof chat.hideSession).toBe('function'); + expect(typeof chat.clearHiddenSessions).toBe('function'); }); it('isActive returns false for unknown client', async () => { diff --git a/server/__tests__/content-blocks.test.ts b/server/__tests__/content-blocks.test.ts new file mode 100644 index 0000000..b5ccfb4 --- /dev/null +++ b/server/__tests__/content-blocks.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { parseContentBlocks, extractToolResultText } from '../content-blocks.js'; + +describe('extractToolResultText', () => { + it('returns string content directly', () => { + expect(extractToolResultText('hello')).toBe('hello'); + }); + + it('concatenates text blocks from array content', () => { + const content = [ + { type: 'text', text: 'line 1\n' }, + { type: 'text', text: 'line 2' }, + ]; + expect(extractToolResultText(content)).toBe('line 1\nline 2'); + }); + + it('ignores non-text blocks', () => { + const content = [{ type: 'text', text: 'ok' }, { type: 'image' }]; + expect(extractToolResultText(content as Parameters[0])).toBe( + 'ok', + ); + }); + + it('returns empty string for undefined', () => { + expect(extractToolResultText(undefined)).toBe(''); + }); +}); + +describe('parseContentBlocks', () => { + it('extracts text from text blocks', () => { + const blocks = [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world' }, + ]; + const result = parseContentBlocks(blocks); + expect(result.text).toBe('Hello world'); + expect(result.toolCalls).toHaveLength(0); + expect(result.toolResults).toHaveLength(0); + }); + + it('extracts tool_use blocks', () => { + const blocks = [ + { type: 'tool_use', name: 'Read', id: 'tc-1', input: { path: '/tmp/file.ts' } }, + ]; + const result = parseContentBlocks(blocks); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].toolName).toBe('Read'); + expect(result.toolCalls[0].toolId).toBe('tc-1'); + expect(result.toolCalls[0].input).toBe('/tmp/file.ts'); + }); + + it('extracts tool_result blocks', () => { + const blocks = [{ type: 'tool_result', tool_use_id: 'tc-1', content: 'file contents here' }]; + const result = parseContentBlocks(blocks); + expect(result.toolResults).toHaveLength(1); + expect(result.toolResults[0].toolId).toBe('tc-1'); + expect(result.toolResults[0].result).toBe('file contents here'); + }); + + it('truncates tool results to 2000 chars', () => { + const blocks = [{ type: 'tool_result', tool_use_id: 'tc-1', content: 'x'.repeat(3000) }]; + const result = parseContentBlocks(blocks); + expect(result.toolResults[0].result.length).toBe(2000); + }); + + it('handles mixed blocks', () => { + const blocks = [ + { type: 'text', text: 'prefix' }, + { type: 'tool_use', name: 'Bash', id: 'tc-2', input: { command: 'ls' } }, + { type: 'tool_result', tool_use_id: 'tc-2', content: 'output' }, + ]; + const result = parseContentBlocks(blocks); + expect(result.text).toBe('prefix'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolResults).toHaveLength(1); + }); +}); diff --git a/server/__tests__/notify.test.ts b/server/__tests__/notify.test.ts index e42d5ee..0e383a7 100644 --- a/server/__tests__/notify.test.ts +++ b/server/__tests__/notify.test.ts @@ -1,20 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { isConfigured, buildNotificationHeaders } from '../notify.js'; +import { isConfigured } from '../notify.js'; describe('notify module', () => { it('isConfigured returns false without NTFY_TOPIC', () => { expect(isConfigured()).toBe(false); }); - - it('buildNotificationHeaders includes tool name in title', () => { - const headers = buildNotificationHeaders('Bash'); - expect(headers.title).toBe('Mitzo: Bash'); - expect(headers.priority).toBe('4'); - expect(headers.tags).toBe('robot'); - }); - - it('buildNotificationHeaders handles different tool names', () => { - expect(buildNotificationHeaders('Edit').title).toBe('Mitzo: Edit'); - expect(buildNotificationHeaders('Write').title).toBe('Mitzo: Write'); - }); }); diff --git a/server/chat.ts b/server/chat.ts index 50fb0fc..4fa382e 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -8,6 +8,7 @@ import { sendPermissionNotification, isConfigured as ntfyConfigured } from './no import { createWorktree } from './worktree.js'; import { SessionRegistry, type MitzoMode } from './session-registry.js'; import { summarizeToolInput } from './tool-summary.js'; +import { parseContentBlocks, extractToolResultText } from './content-blocks.js'; export type { MitzoMode } from './session-registry.js'; @@ -55,6 +56,109 @@ function send(ws: WebSocket, data: unknown) { } } +function resolveWorktree( + ws: WebSocket, + baseCwd: string, + options: { resume?: string; cwd?: string; worktree?: boolean }, +): { cwd: string; worktreePath?: string } { + if ( + !( + WORKTREE_ENABLED && + options.worktree !== false && + !options.cwd && + !options.resume && + BASE_REPO + ) + ) { + return { cwd: baseCwd }; + } + const wtId = `wt-${Date.now().toString(36)}`; + try { + const worktreePath = createWorktree(wtId, BASE_REPO); + send(ws, { type: 'worktree', path: worktreePath }); + return { cwd: worktreePath, worktreePath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error('[worktree] creation failed, using base repo:', message); + send(ws, { type: 'error', error: `Worktree creation failed (using base repo): ${message}` }); + return { cwd: baseCwd }; + } +} + +function stageImages(cwd: string, images: Array<{ data: string; mediaType: string }>): string[] { + const imgDir = join(cwd, '.mitzo-images'); + mkdirSync(imgDir, { recursive: true }); + + const extMap: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + }; + + const paths: string[] = []; + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const ext = extMap[img.mediaType] || '.jpg'; + const filename = `image-${Date.now()}-${i}${ext}`; + const filePath = join(imgDir, filename); + writeFileSync(filePath, Buffer.from(img.data, 'base64')); + paths.push(filePath); + } + return paths; +} + +function buildPermissionHandler(clientId: string) { + return async ( + toolName: string, + toolInput: Record, + opts: { suggestions?: unknown[] }, + ) => { + const session = registry.get(clientId); + if (!session) return { behavior: 'deny' as const, message: 'Session not found' }; + + if (session.mode === 'auto') return { behavior: 'allow' as const }; + if (session.sessionAllowList.has(toolName)) { + return { behavior: 'allow' as const, decisionClassification: 'user_permanent' as const }; + } + + const inputSummary = summarizeToolInput(toolName, toolInput); + + return new Promise<{ + behavior: string; + message?: string; + decisionClassification?: string; + updatedPermissions?: unknown[]; + }>((resolve) => { + const permId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const wrappedResolve = (result: { behavior: string; decisionClassification?: string }) => { + if (result.behavior === 'allow' && result.decisionClassification === 'user_permanent') { + session.sessionAllowList.add(toolName); + } + resolve(result as typeof result & { updatedPermissions?: unknown[] }); + }; + + registerPending(permId, toolName, wrappedResolve, opts?.suggestions); + send(session.ws, { type: 'permission_request', permId, toolName, toolInput: inputSummary }); + + if (ntfyConfigured()) { + setTimeout(() => { + if (hasPending(permId)) sendPermissionNotification(toolName, inputSummary, permId); + }, 10_000); + } + + setTimeout(() => { + if (hasPending(permId)) { + removePending(permId); + resolve({ behavior: 'deny', message: 'Permission request timed out' }); + send(session.ws, { type: 'permission_timeout', permId }); + } + }, 120_000); + }); + }; +} + export async function startChat( ws: WebSocket, clientId: string, @@ -71,51 +175,13 @@ export async function startChat( ) { const abortController = new AbortController(); const mode = options.mode || 'agent'; - const sessionAllowList = new Set(); - - let cwd = options.cwd || BASE_REPO; - let worktreePath: string | undefined; + const baseCwd = options.cwd || BASE_REPO; - const useWorktree = - WORKTREE_ENABLED && options.worktree !== false && !options.cwd && !options.resume && BASE_REPO; - - if (useWorktree) { - const wtId = `wt-${Date.now().toString(36)}`; - try { - worktreePath = createWorktree(wtId, BASE_REPO); - cwd = worktreePath; - send(ws, { type: 'worktree', path: worktreePath }); - } catch (err: any) { - console.error('[worktree] creation failed, using base repo:', err.message); - send(ws, { - type: 'error', - error: `Worktree creation failed (using base repo): ${err.message}`, - }); - } - } + const { cwd, worktreePath } = resolveWorktree(ws, baseCwd, options); let fullPrompt = prompt; if (options.images?.length) { - const imgDir = join(cwd, '.mitzo-images'); - mkdirSync(imgDir, { recursive: true }); - - const paths: string[] = []; - const extMap: Record = { - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/gif': '.gif', - 'image/webp': '.webp', - }; - - for (let i = 0; i < options.images.length; i++) { - const img = options.images[i]; - const ext = extMap[img.mediaType] || '.jpg'; - const filename = `image-${Date.now()}-${i}${ext}`; - const filePath = join(imgDir, filename); - writeFileSync(filePath, Buffer.from(img.data, 'base64')); - paths.push(filePath); - } - + const paths = stageImages(cwd, options.images); const imageRefs = paths.map((p) => `- ${p}`).join('\n'); fullPrompt = `${prompt}\n\nI've attached ${paths.length} image(s). Read them using the Read tool:\n${imageRefs}`; } @@ -127,7 +193,7 @@ export async function startChat( ws, abortController, mode, - sessionAllowList, + sessionAllowList: new Set(), worktreePath, }); @@ -140,67 +206,18 @@ export async function startChat( includePartialMessages: true, settingSources: ['project'], systemPrompt: { type: 'preset', preset: 'claude_code' }, - permissionMode: MODE_TO_SDK[mode] as any, + permissionMode: MODE_TO_SDK[mode] as 'plan' | 'default' | 'bypassPermissions', allowedTools: [...baseAllowed, ...extraTools], ...(options.model ? { model: options.model } : {}), ...(options.resume ? { resume: options.resume } : {}), - canUseTool: async (toolName: string, toolInput: Record, opts: any) => { - const session = registry.get(clientId); - if (!session) return { behavior: 'deny' as const, message: 'Session not found' }; - - if (session.mode === 'auto') { - return { behavior: 'allow' as const }; - } - - if (session.sessionAllowList.has(toolName)) { - return { behavior: 'allow' as const, decisionClassification: 'user_permanent' as const }; - } - - const inputSummary = summarizeToolInput(toolName, toolInput); - - return new Promise((resolve) => { - const permId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const wrappedResolve = (result: any) => { - if (result.behavior === 'allow' && result.decisionClassification === 'user_permanent') { - session.sessionAllowList.add(toolName); - } - resolve(result); - }; - - registerPending(permId, toolName, wrappedResolve, opts?.suggestions); - - send(session.ws, { - type: 'permission_request', - permId, - toolName, - toolInput: inputSummary, - }); - - if (ntfyConfigured()) { - setTimeout(() => { - if (hasPending(permId)) { - sendPermissionNotification(toolName, inputSummary, permId); - } - }, 10_000); - } - - setTimeout(() => { - if (hasPending(permId)) { - removePending(permId); - resolve({ behavior: 'deny' as const, message: 'Permission request timed out' }); - send(session.ws, { type: 'permission_timeout', permId }); - } - }, 120_000); - }); - }, + canUseTool: buildPermissionHandler(clientId) as any, // SDK typing requires broad compat }, }); const session = registry.get(clientId)!; session.queryInstance = q; - const messageHandler = (raw: any) => { + const messageHandler = (raw: Buffer) => { try { const msg = JSON.parse(raw.toString()); if (msg.type === 'permission_response' && msg.permId) { @@ -241,9 +258,7 @@ export async function startChat( send(currentWs, { type: 'session_id', sessionId: msg.session_id }); } } else if (msg.type === 'result') { - if (msg.session_id) { - send(currentWs, { type: 'session_id', sessionId: msg.session_id }); - } + if (msg.session_id) send(currentWs, { type: 'session_id', sessionId: msg.session_id }); send(currentWs, { type: 'done', sessionId: msg.session_id }); } else if (msg.type === 'stream_event') { const evt = msg.event; @@ -251,17 +266,11 @@ export async function startChat( send(currentWs, { type: 'text_delta', text: evt.delta.text }); } } else if (msg.type === 'user' && msg.tool_use_result !== undefined) { - const content = (msg.message as any)?.content; + const content = (msg.message as unknown as Record)?.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_result') { - let resultText = ''; - if (typeof block.content === 'string') resultText = block.content; - else if (Array.isArray(block.content)) { - for (const c of block.content) { - if (c.type === 'text') resultText += c.text; - } - } + const resultText = extractToolResultText(block.content); send(currentWs, { type: 'tool_result', toolId: block.tool_use_id || '', @@ -272,10 +281,11 @@ export async function startChat( } } } - } catch (err: any) { + } catch (err: unknown) { const currentSession = registry.get(clientId); if (currentSession && !abortController.signal.aborted) { - send(currentSession.ws, { type: 'error', error: err.message || 'Unknown error' }); + const message = err instanceof Error ? err.message : 'Unknown error'; + send(currentSession.ws, { type: 'error', error: message }); } } finally { ws.removeListener('message', messageHandler); @@ -293,15 +303,12 @@ export async function startChat( export function stopChat(clientId: string) { registry.abort(clientId); } - export function detachChat(clientId: string) { registry.detach(clientId); } - export function reattachChat(clientId: string, ws: WebSocket): boolean { return registry.reattach(clientId, ws); } - export function isActive(clientId: string): boolean { return registry.isActive(clientId); } @@ -315,7 +322,7 @@ function getSessionDirs(): string[] { if (e.startsWith('session-')) dirs.push(join(sessionsDir, e)); } } catch { - // No sessions dir yet + /* No sessions dir yet */ } const claudeProjects = join(homedir(), '.claude', 'projects'); const prefix = BASE_REPO.replace(/\//g, '-').replace(/^-/, '-'); @@ -328,17 +335,15 @@ function getSessionDirs(): string[] { } } } catch { - // No claude projects dir + /* No claude projects dir */ } return dirs; } const hiddenSessionIds = new Set(); - export function hideSession(sessionId: string) { hiddenSessionIds.add(sessionId); } - export function clearHiddenSessions() { hiddenSessionIds.clear(); } @@ -364,7 +369,7 @@ export async function getSessions() { } } } catch { - // Dir might not exist — fine + /* Dir might not exist */ } } const deduped = Array.from(seen.values()); @@ -373,53 +378,34 @@ export async function getSessions() { } export async function getMessages(sessionId: string) { - let messages: any[] = []; + let rawMessages: Array<{ type: string; message?: Record }> = []; for (const dir of getSessionDirs()) { try { - messages = await getSessionMessages(sessionId, { dir, limit: 100 }); - if (messages.length > 0) break; + rawMessages = (await getSessionMessages(sessionId, { + dir, + limit: 100, + })) as typeof rawMessages; + if (rawMessages.length > 0) break; } catch { - // Try next dir + /* Try next dir */ } } try { - return messages + return rawMessages .map((m) => { - const content = (m.message as any)?.content; - let text = ''; - const toolCalls: any[] = []; - const toolResults: any[] = []; - - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'text') text += block.text; - else if (block.type === 'tool_use') { - toolCalls.push({ - toolName: block.name, - toolId: block.id, - input: summarizeToolInput(block.name, block.input), - }); - } else if (block.type === 'tool_result') { - let rt = ''; - if (typeof block.content === 'string') rt = block.content; - else if (Array.isArray(block.content)) { - for (const c of block.content) { - if (c.type === 'text') rt += c.text; - } - } - toolResults.push({ toolId: block.tool_use_id, result: rt.slice(0, 2000) }); - } - } - } - + const content = m.message?.content; + if (!Array.isArray(content)) return null; + const parsed = parseContentBlocks(content); return { role: m.type, - text: text || undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - toolResults: toolResults.length > 0 ? toolResults : undefined, + text: parsed.text || undefined, + toolCalls: parsed.toolCalls.length > 0 ? parsed.toolCalls : undefined, + toolResults: parsed.toolResults.length > 0 ? parsed.toolResults : undefined, }; }) - .filter((m) => m.text || m.toolCalls || m.toolResults); + .filter( + (m): m is NonNullable => m !== null && !!(m.text || m.toolCalls || m.toolResults), + ); } catch { return []; } diff --git a/server/content-blocks.ts b/server/content-blocks.ts new file mode 100644 index 0000000..ac793b2 --- /dev/null +++ b/server/content-blocks.ts @@ -0,0 +1,66 @@ +import { summarizeToolInput } from './tool-summary.js'; + +interface ContentBlock { + type: string; + text?: string; + name?: string; + id?: string; + input?: Record; + content?: string | Array<{ type: string; text?: string }>; + tool_use_id?: string; +} + +interface ParsedToolCall { + toolName: string; + toolId: string; + input: string; +} + +interface ParsedToolResult { + toolId: string; + result: string; +} + +interface ParsedContent { + text: string; + toolCalls: ParsedToolCall[]; + toolResults: ParsedToolResult[]; +} + +export function extractToolResultText( + content: string | Array<{ type: string; text?: string }> | undefined, +): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let text = ''; + for (const c of content) { + if (c.type === 'text' && c.text) text += c.text; + } + return text; +} + +export function parseContentBlocks(blocks: ContentBlock[]): ParsedContent { + let text = ''; + const toolCalls: ParsedToolCall[] = []; + const toolResults: ParsedToolResult[] = []; + + for (const block of blocks) { + if (block.type === 'text' && block.text) { + text += block.text; + } else if (block.type === 'tool_use' && block.name) { + toolCalls.push({ + toolName: block.name, + toolId: block.id || '', + input: summarizeToolInput(block.name, (block.input || {}) as Record), + }); + } else if (block.type === 'tool_result') { + const rt = extractToolResultText(block.content); + toolResults.push({ + toolId: block.tool_use_id || '', + result: rt.slice(0, 2000), + }); + } + } + + return { text, toolCalls, toolResults }; +} diff --git a/server/notify.ts b/server/notify.ts index 2fbf748..02bb8c3 100644 --- a/server/notify.ts +++ b/server/notify.ts @@ -15,9 +15,10 @@ export async function sendPermissionNotification( if (!NTFY_TOPIC || !BASE_URL) return; const truncatedInput = toolInput.length > 100 ? toolInput.slice(0, 100) + '...' : toolInput; + const token = NTFY_AUTH_TOKEN || ''; - const allowUrl = `${BASE_URL}/api/permission/${permId}/respond?decision=once&token=${NTFY_AUTH_TOKEN || ''}`; - const denyUrl = `${BASE_URL}/api/permission/${permId}/respond?decision=deny&token=${NTFY_AUTH_TOKEN || ''}`; + const allowUrl = `${BASE_URL}/api/permission/${permId}/respond?decision=once&token=${token}`; + const denyUrl = `${BASE_URL}/api/permission/${permId}/respond?decision=deny&token=${token}`; const headers: Record = { Title: `Mitzo: ${toolName}`, @@ -40,11 +41,3 @@ export async function sendPermissionNotification( console.error('[ntfy] failed to send notification:', err); } } - -export function buildNotificationHeaders(toolName: string): Record { - return { - title: `Mitzo: ${toolName}`, - priority: '4', - tags: 'robot', - }; -}