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 3f61fd6..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,36 +29,57 @@ export function ChatView() { const scrollRef = useRef(null); const reconnectTimer = useRef | null>(null); const intentionalClose = useRef(false); + 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', - }); + 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 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( - (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) { @@ -121,139 +97,198 @@ 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 }); } } } if (loaded.length > 0) { setMessages(loaded); - setTimeout(scrollToBottom, 100); + setTimeout(forceScrollToBottom, 100); } }, ) .catch(() => {}); - }, [sessionId, scrollToBottom]); + }, [sessionId, forceScrollToBottom]); + + useEffect(() => { + if (currentSessionId && messages.length > 0) { + sessionStorage.setItem(`mitzo-chat-${currentSessionId}`, JSON.stringify(messages)); + } + }, [messages, currentSessionId]); - const connectWs = useCallback(() => { - const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - const ws = new WebSocket(`${proto}://${location.host}/ws/chat`); - wsRef.current = ws; + useEffect(() => { + intentionalClose.current = false; - ws.onopen = () => { - setConnected(true); - }; + 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.onmessage = (event) => { - const msg = JSON.parse(event.data); + ws.onopen = () => setConnected(true); - switch (msg.type) { - case 'session_id': - setCurrentSessionId(msg.sessionId); - break; + ws.onmessage = (event) => { + let msg: Record; + try { + msg = JSON.parse(event.data as string); + } catch { + return; + } - case 'text_delta': - streamBuf.current += msg.text; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === 'assistant' && last.streaming) { + switch (msg.type) { + case 'client_id': + if (wasRunning.current && serverClientId.current) { + ws.send(JSON.stringify({ type: 'reattach', clientId: serverClientId.current })); + } + serverClientId.current = msg.clientId as string; + break; + + case 'reattached': + serverClientId.current = msg.clientId as string; + setRunning(true); + if (msg.sessionId) setCurrentSessionId(msg.sessionId as string); + break; + + case 'reattach_failed': + wasRunning.current = false; + setRunning(false); + break; + + case 'session_id': + setCurrentSessionId(msg.sessionId as string); + break; + + case 'text_delta': + 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' as const, text: streamBuf.current, streaming: true }, + ]; + } return [ - ...prev.slice(0, -1), - { role: 'assistant', text: streamBuf.current, streaming: true }, + ...prev, + { role: 'assistant' as const, 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' as const, text: msg.text as string }, + ]; + } + return [...prev, { role: 'assistant' as const, text: msg.text as string }]; + }); + scrollToBottom(); + break; + + case 'tool_call': + streamBuf.current = ''; + 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 as string } : m, + ), + ); + scrollToBottom(); + break; + + case 'permission_request': + setPermission({ + permId: msg.permId as string, + toolName: msg.toolName as string, + toolInput: msg.toolInput as string, + }); + 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' as const, text }]; + } + return [...prev, { role: 'assistant' as const, 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 }]; - } - 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); + setRunning(false); + wasRunning.current = false; + if (msg.sessionId) setCurrentSessionId(msg.sessionId as string); + break; } - 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 + case 'error': + streamBuf.current = ''; + setRunning(false); + wasRunning.current = false; + if ((msg.error as string)?.includes('No conversation found')) { + setCurrentSessionId(undefined); + setMessages((prev) => [ + ...prev, + { + role: 'assistant' as const, + text: 'Session expired. Send your message again to start fresh.', + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { role: 'assistant' as const, text: `**Error:** ${msg.error}` }, + ]); + } + scrollToBottom(); + break; + } + }; + + ws.onclose = () => { + setConnected(false); + wsRef.current = null; + if (!intentionalClose.current) { + const delay = 2000 + Math.random() * 2000; + reconnectTimer.current = setTimeout(connectWs, delay); + } + }; + + ws.onerror = () => {}; + } - 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 +296,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,18 +310,21 @@ 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 }; + 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 })); @@ -293,28 +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; ws.send(JSON.stringify(payload)); - scrollToBottom(); + 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); @@ -332,7 +370,10 @@ export function ChatView() { ← {!connected && ( - + ! )} diff --git a/frontend/src/pages/SessionList.tsx b/frontend/src/pages/SessionList.tsx index 693bb3c..f9ca782 100644 --- a/frontend/src/pages/SessionList.tsx +++ b/frontend/src/pages/SessionList.tsx @@ -1,12 +1,7 @@ -import { useState, useEffect } from 'react'; +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,15 +55,74 @@ 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, + 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() { @@ -93,6 +147,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 +195,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/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/__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/chat.ts b/server/chat.ts index a3c86d2..4fa382e 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1,33 +1,27 @@ 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'; +import { parseContentBlocks, extractToolResultText } from './content-blocks.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 +50,115 @@ 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)); + } +} + +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, @@ -72,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; + const { cwd, worktreePath } = resolveWorktree(ws, baseCwd, options); - if (useWorktree) { - try { - worktreePath = createWorktree(clientId, 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}`, - }); - } - } - - // 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'); - 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}`; } @@ -124,14 +189,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: new Set(), worktreePath, - }; + }); const q = query({ prompt: fullPrompt, @@ -142,71 +206,25 @@ 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) => { - if (session.mode === 'auto') { - return { behavior: 'allow' as const }; - } - - if (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') { - sessionAllowList.add(toolName); - } - resolve(result); - }; - - registerPending(permId, toolName, wrappedResolve, opts?.suggestions); - - send(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(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; - activeSessions.set(clientId, session); - const messageHandler = (raw: any) => { + const messageHandler = (raw: Buffer) => { try { const msg = JSON.parse(raw.toString()); 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 +234,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,33 +253,25 @@ 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(ws, { type: 'done', 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; 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; + 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; - } - } - send(ws, { + const resultText = extractToolResultText(block.content); + send(currentWs, { type: 'tool_result', toolId: block.tool_use_id || '', result: resultText.slice(0, 2000), @@ -269,130 +281,132 @@ export async function startChat( } } } - } catch (err: any) { - if (!abortController.signal.aborted) { - send(ws, { type: 'error', error: err.message || 'Unknown error' }); + } catch (err: unknown) { + const currentSession = registry.get(clientId); + if (currentSession && !abortController.signal.aborted) { + const message = err instanceof Error ? err.message : 'Unknown error'; + send(currentSession.ws, { type: 'error', error: message }); } } 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 registry.isActive(clientId); +} + +function getSessionDirs(): string[] { + const dirs = [BASE_REPO]; + const sessionsDir = `${BASE_REPO}-sessions`; + try { + const entries = readdirSync(sessionsDir); + for (const e of entries) { + if (e.startsWith('session-')) dirs.push(join(sessionsDir, e)); + } + } catch { + /* 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 function isActive(clientId: string): boolean { - return activeSessions.has(clientId); +const hiddenSessionIds = new Set(); +export function hideSession(sessionId: string) { + hiddenSessionIds.add(sessionId); +} +export function clearHiddenSessions() { + hiddenSessionIds.clear(); } export async function getSessions() { - 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, - })); - } catch { - return []; + 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) { + 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 */ + } } + 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) { + let rawMessages: Array<{ type: string; message?: Record }> = []; + for (const dir of getSessionDirs()) { + try { + rawMessages = (await getSessionMessages(sessionId, { + dir, + limit: 100, + })) as typeof rawMessages; + if (rawMessages.length > 0) break; + } catch { + /* Try next dir */ + } + } try { - const messages = await getSessionMessages(sessionId, { dir: BASE_REPO, limit: 100 }); - 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 []; } } - -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/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/index.ts b/server/index.ts index 1bc9606..044f9a9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,19 +3,24 @@ 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, + hideSession, + clearHiddenSessions, AVAILABLE_MODELS, BASE_REPO, + registry, } from './chat.js'; import { cleanupStaleWorktrees, listWorktrees } from './worktree.js'; @@ -103,10 +108,79 @@ 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)); }); +// 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 +217,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 +270,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); }); } 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', - }; -} 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); + } +}