diff --git a/server/session-manager.ts b/server/session-manager.ts index 030d6a1..8b0c9f1 100644 --- a/server/session-manager.ts +++ b/server/session-manager.ts @@ -7,6 +7,7 @@ import { basename } from 'path'; import type { ClientConnection } from './transport/client-connection.js'; import { sessionReviews, sessionAdapters } from './db.js'; import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js'; +import { TaskAggregator, TASK_TOOL_NAMES, TASK_TOOLS_ON_START } from './stores/task-aggregator.js'; /** Push notification options */ interface PushOptions { @@ -53,6 +54,22 @@ const sessionAdapterMap = new Map(); // sessionId - // under the old key. This alias map resolves old → new so late-connecting clients // find the correct session. const rekeyAliases = new Map(); // oldKey -> newKey +const sessionTaskState = new Map(); // sessionId -> task aggregator + +function getOrCreateAggregator(sessionId: string): TaskAggregator { + let agg = sessionTaskState.get(sessionId); + if (!agg) { + agg = new TaskAggregator(); + sessionTaskState.set(sessionId, agg); + } + return agg; +} + +function broadcastTaskState(sessionId: string): void { + const aggregator = sessionTaskState.get(sessionId); + if (!aggregator?.hasTasks) return; + broadcast(sessionId, { type: WS.TASK_STATE, ...aggregator.getSnapshot() }); +} export function setupSessionManager(): void { const adapters = getAllAdapters(); @@ -69,11 +86,24 @@ export function setupSessionManager(): void { adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => { console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`); broadcast(sessionId, { type: WS.TOOL_START, ...data }); + + // TaskUpdate/TodoWrite can be processed on start (input is sufficient). + // TaskCreate is deferred to tool-done because we need the result text for the assigned ID. + if (TASK_TOOLS_ON_START.has(data.toolName)) { + getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record) || {}); + broadcastTaskState(sessionId); + } }); - adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => { + adapter.on('tool-done', (sessionId: string, data: { toolName: string; result?: any; [key: string]: unknown }) => { console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`); broadcast(sessionId, { type: WS.TOOL_DONE, ...data }); + + if (TASK_TOOL_NAMES.has(data.toolName)) { + const resultText = typeof data.result?.content === 'string' ? data.result.content : ''; + getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record) || {}, resultText); + broadcastTaskState(sessionId); + } }); adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => { @@ -166,6 +196,7 @@ export function setupSessionManager(): void { // THEN clean up maps sessionClients.delete(sessionId); sessionAdapterMap.delete(sessionId); + sessionTaskState.delete(sessionId); // Clean rekey alias pointing to this session for (const [oldKey, newKey] of rekeyAliases) { if (newKey === sessionId) rekeyAliases.delete(oldKey); @@ -214,6 +245,12 @@ export function setupSessionManager(): void { sessionAdapterMap.delete(oldKey); sessionAdapterMap.set(newKey, adapterName); } + // Move task state + const taskState = sessionTaskState.get(oldKey); + if (taskState) { + sessionTaskState.delete(oldKey); + sessionTaskState.set(newKey, taskState); + } // Update any active reviews that reference the old key as child (FIX 3) sessionReviews.updateChildCliId(oldKey, newKey); // Send SESSION_CREATED with the real UUID — for pendingRekey adapters, @@ -438,16 +475,38 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string // that would duplicate what HISTORY_LOAD delivers adapter.syncWatcherPosition(resolvedId); - // Send current messages from store (full history for reconnection) + const isStreaming = adapter.isProcessing(resolvedId); + let historyMessages: unknown[] = []; try { - const { messages } = await adapter.getMessages(resolvedId); - if (messages.length > 0) { - send(conn, { type: WS.HISTORY_LOAD, messages }); - } + ({ messages: historyMessages } = await adapter.getMessages(resolvedId)); } catch {} + // Rebuild task state from history if not already cached (e.g. after server restart) + if (!sessionTaskState.has(resolvedId) && historyMessages.length > 0) { + const aggregator = new TaskAggregator(); + for (const msg of historyMessages as Array<{ role?: string; content?: any[] }>) { + if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block.type === 'tool_use' && TASK_TOOL_NAMES.has(block.name)) { + const resultText = block._result?.content; + aggregator.processToolUse(block.name, block.input || {}, typeof resultText === 'string' ? resultText : undefined); + } + } + } + if (aggregator.hasTasks) { + sessionTaskState.set(resolvedId, aggregator); + } + } - // Notify client if session is actively processing - if (adapter.isProcessing(resolvedId)) { + send(conn, { type: WS.HISTORY_LOAD, messages: historyMessages, streaming: isStreaming }); + + // Send accumulated task state if available + const taskAgg = sessionTaskState.get(resolvedId); + if (taskAgg?.hasTasks) { + send(conn, { type: WS.TASK_STATE, ...taskAgg.getSnapshot() }); + } + + // Fallback: client may receive broadcasts before HISTORY_LOAD during the async gap + if (isStreaming) { send(conn, { type: WS.SESSION_STATE, streaming: true }); } diff --git a/server/stores/task-aggregator.ts b/server/stores/task-aggregator.ts new file mode 100644 index 0000000..83c7bbb --- /dev/null +++ b/server/stores/task-aggregator.ts @@ -0,0 +1,124 @@ +/** + * Pure aggregator: processes tool_use blocks (TaskCreate, TaskUpdate, TodoWrite) + * and produces a unified AggregatedTask[] snapshot. + * + * Stateful — call processToolUse() for each relevant tool call in order. + * Call getSnapshot() to read current state (cached, invalidated on changes). + */ + +export interface AggregatedTask { + id: string; + subject: string; + description?: string; + activeForm?: string; + status: 'pending' | 'in_progress' | 'completed'; + blockedBy?: string[]; + blocks?: string[]; + source: 'task-api' | 'todo-write'; +} + +export interface TaskSnapshot { + tasks: AggregatedTask[]; + completed: number; + total: number; +} + +/** Tool names that produce task state changes. */ +export const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite']); +/** Subset handled on tool-start (TaskCreate needs result text, so it's deferred to tool-done). */ +export const TASK_TOOLS_ON_START = new Set(['TaskUpdate', 'TodoWrite']); + +export class TaskAggregator { + private taskApiTasks = new Map(); + private todoWriteTasks = new Map(); + private cachedSnapshot: TaskSnapshot | null = null; + + /** Process a single tool_use block. Returns true if state changed. */ + processToolUse(toolName: string, input: Record, resultText?: string): boolean { + switch (toolName) { + case 'TaskCreate': { + const match = resultText?.match(/Task #(\d+)/); + const id = match?.[1] || String(this.taskApiTasks.size + 1); + this.taskApiTasks.set(id, { + id, + subject: (input.subject as string) || '', + description: (input.description as string) || undefined, + activeForm: (input.activeForm as string) || undefined, + status: 'pending', + source: 'task-api', + }); + this.cachedSnapshot = null; + return true; + } + + case 'TaskUpdate': { + const taskId = input.taskId as string; + if (!taskId) return false; + const existing = this.taskApiTasks.get(taskId); + if (!existing) return false; + if (input.status === 'deleted') { + this.taskApiTasks.delete(taskId); + this.cachedSnapshot = null; + return true; + } + const updated: AggregatedTask = { ...existing }; + if (input.status) updated.status = input.status as AggregatedTask['status']; + if (input.subject) updated.subject = input.subject as string; + if (input.description) updated.description = input.description as string; + if (input.activeForm) updated.activeForm = input.activeForm as string; + if (input.addBlockedBy) { + updated.blockedBy = [...(updated.blockedBy || []), ...(input.addBlockedBy as string[])]; + } + if (input.addBlocks) { + updated.blocks = [...(updated.blocks || []), ...(input.addBlocks as string[])]; + } + this.taskApiTasks.set(taskId, updated); + this.cachedSnapshot = null; + return true; + } + + case 'TodoWrite': { + this.todoWriteTasks.clear(); + const tasks = (input.tasks || input.todos || []) as Array<{ + id: string; + content: string; + status: string; + }>; + for (const t of tasks) { + this.todoWriteTasks.set(t.id, { + id: t.id, + subject: t.content || '', + status: (t.status as AggregatedTask['status']) || 'pending', + source: 'todo-write', + }); + } + this.cachedSnapshot = null; + return true; + } + + default: + return false; + } + } + + getSnapshot(): TaskSnapshot { + if (this.cachedSnapshot) return this.cachedSnapshot; + const tasks: AggregatedTask[] = [ + ...this.taskApiTasks.values(), + ...this.todoWriteTasks.values(), + ]; + const completed = tasks.filter(t => t.status === 'completed').length; + this.cachedSnapshot = { tasks, completed, total: tasks.length }; + return this.cachedSnapshot; + } + + get hasTasks(): boolean { + return this.taskApiTasks.size > 0 || this.todoWriteTasks.size > 0; + } + + clear(): void { + this.taskApiTasks.clear(); + this.todoWriteTasks.clear(); + this.cachedSnapshot = null; + } +} diff --git a/server/ws-types.ts b/server/ws-types.ts index 66f3a74..7ec7db4 100644 --- a/server/ws-types.ts +++ b/server/ws-types.ts @@ -37,6 +37,8 @@ export const WS = { // Cross-AI Review REVIEW_STARTED: 'review-started', REVIEW_ENDED: 'review-ended', + // Task Progress + TASK_STATE: 'task-state', } as const; export type WsType = typeof WS[keyof typeof WS]; diff --git a/src/components/ChatBody.tsx b/src/components/ChatBody.tsx index 98ad259..e3a6bb6 100644 --- a/src/components/ChatBody.tsx +++ b/src/components/ChatBody.tsx @@ -1,8 +1,8 @@ -import React, { useRef, useState, useEffect, useMemo, Fragment } from 'react'; +import React, { useRef, useState, useEffect, useCallback, useMemo, Fragment } from 'react'; +import { ChevronDown } from 'lucide-react'; import type { ChatMessage, ToolStatus } from '../hooks/useChat'; import { MessageBubble } from './MessageBubble'; import { ToolCallCard } from './ToolCallCard'; -import { TaskProgress } from './TaskProgress'; import { SubagentGroup } from './SubagentGroup'; import { ShimmerInput } from './ShimmerInput'; @@ -16,7 +16,6 @@ export interface ChatBodyProps { toolStatuses: Map; onSend: (text: string) => void; onStop: () => void; - disabled: boolean; interrupted: boolean; sendTargets?: { adapter: string; label: string }[]; onSendTo?: (messageId: string, adapter?: string) => void; @@ -48,7 +47,6 @@ export function ChatBody({ toolStatuses, onSend, onStop, - disabled, interrupted, sendTargets, onSendTo, @@ -67,12 +65,19 @@ export function ChatBody({ const scrollRef = scrollContainerRef || internalRef; const [userScrolled, setUserScrolled] = useState(false); - // Auto-scroll to bottom when new messages arrive, unless user scrolled up + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + }, [scrollRef]); + + // Auto-scroll when new content arrives useEffect(() => { - if (!userScrolled && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (!userScrolled) { + scrollToBottom(); } - }, [messages, userScrolled]); + }, [messages, streaming, userScrolled, scrollToBottom]); function handleScroll() { const el = scrollRef.current; @@ -95,10 +100,9 @@ export function ChatBody({ ? new Set(content.filter((b: any) => b.type === 'tool_result').map((b: any) => b.tool_use_id)) : null; - const taskBlocks = toolBlocks.filter((b: any) => b.name === 'TodoWrite'); const planBlocks = toolBlocks.filter((b: any) => b.name === 'ExitPlanMode' && b.input?.plan); const regularTools = toolBlocks.filter( - (b: any) => !['TodoWrite', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name), + (b: any) => !['TodoWrite', 'TaskCreate', 'TaskUpdate', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name), ); const subagentGroups = new Map(); @@ -151,10 +155,6 @@ export function ChatBody({ } } - for (const task of taskBlocks) { - elements.push(); - } - for (const plan of planBlocks) { if (renderPlanBlock) { const node = renderPlanBlock(plan, hasPlanResponse, plan.id); @@ -243,12 +243,23 @@ export function ChatBody({ )} + {/* Scroll-to-bottom button */} + {userScrolled && ( + + )} + {renderAboveInput?.()} {/* Input */}
{!hideInput ? ( - + ) : (
Review ended — read only diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 44cd8ca..fb29793 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react'; import { useChat } from '../hooks/useChat'; +import { useVisualViewport } from '../hooks/useVisualViewport'; import { PLAN_OPTION } from '../lib/ws-types'; import { InteractivePromptOverlay } from './InteractivePromptOverlay'; import { StatusBar } from './StatusBar'; @@ -10,6 +11,8 @@ import { ReviewActionMenu } from './ReviewActionMenu'; import { SendToExistingSheet } from './SendToExistingSheet'; import { CollapsedReviewCard } from './CollapsedReviewCard'; import { BlockMarker } from './BlockMarker'; +import { TaskFab } from './TaskFab'; +import { TaskBottomSheet } from './TaskBottomSheet'; import { api } from '../lib/api'; import { getBrand } from '../lib/adapter-brands'; import { extractTextFromBlocks } from '../lib/content-utils'; @@ -136,6 +139,7 @@ export function ChatView({ const reviewPanelRef = useRef(null); const reviewRefetchTimer = useRef | null>(null); const headerHidden = useAutoHideHeader(chatScrollRef); + const viewportHeight = useVisualViewport(); const { messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus, @@ -143,10 +147,12 @@ export function ChatView({ queuedMessage, clearQueuedMessage, activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel, historyReview, setHistoryReview, + taskSnapshot, sendMessage, respondPrompt, respondPlan, abort, updateModel, updatePermissionMode, } = useChat(initialSessionId, cwd, adapter, initialPrompt); + const [taskSheetOpen, setTaskSheetOpen] = useState(false); const [availableAdapters, setAvailableAdapters] = useState([]); useEffect(() => { api.adapters() @@ -477,7 +483,10 @@ export function ChatView({ } return ( -
+
{/* Header — auto-hides when scrolling up to view history */}
)} + {/* Task progress FAB + bottom sheet */} + setTaskSheetOpen(true)} /> + setTaskSheetOpen(false)} /> + {/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */} {interactivePrompt && ( void; +} + +function TaskRow({ task, taskMap }: { task: AggregatedTask; taskMap: Map }) { + const [expanded, setExpanded] = useState(false); + + const blockers = useMemo(() => { + if (!task.blockedBy?.length) return []; + return task.blockedBy + .map(id => taskMap.get(`${task.source}:${id}`)) + .filter((b): b is AggregatedTask => !!b && b.status !== 'completed'); + }, [task.blockedBy, task.source, taskMap]); + + const isBlocked = blockers.length > 0; + const hasExpandable = task.description || task.activeForm; + + return ( +
+
hasExpandable && setExpanded(!expanded)} + > + + {task.status === 'completed' ? ( + + ) : task.status === 'in_progress' ? ( + + ) : ( + + )} + + + + {task.subject} + + + {isBlocked && ( + + blocked + + )} + + {hasExpandable && ( + + {expanded ? ( + + ) : ( + + )} + + )} +
+ + {task.status === 'in_progress' && task.activeForm && ( +
+ {task.activeForm} +
+ )} + + {isBlocked && ( +
+ waiting: {blockers.map(b => b.subject).join(', ')} +
+ )} + + {expanded && task.description && ( +
+ {task.description} +
+ )} +
+ ); +} + +export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProps) { + const { tasks, completed, total } = snapshot; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + const taskMap = useMemo(() => { + const map = new Map(); + for (const t of tasks) map.set(`${t.source}:${t.id}`, t); + return map; + }, [tasks]); + + return ( + +
+ Tasks + {completed}/{total} +
+ +
+ +
+ +
+ {tasks.map(task => ( + + ))} +
+
+ ); +} diff --git a/src/components/TaskFab.tsx b/src/components/TaskFab.tsx new file mode 100644 index 0000000..df3ba3c --- /dev/null +++ b/src/components/TaskFab.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from 'react'; +import type { TaskSnapshot } from '../hooks/useTaskState'; + +type FabState = 'hidden' | 'visible' | 'fading'; + +interface TaskFabProps { + snapshot: TaskSnapshot; + onClick: () => void; +} + +export function TaskFab({ snapshot, onClick }: TaskFabProps) { + const { completed, total } = snapshot; + const [fabState, setFabState] = useState('hidden'); + + const allDone = total > 0 && completed === total; + const pct = total > 0 ? completed / total : 0; + + useEffect(() => { + if (total === 0) { + setFabState('hidden'); + return; + } + setFabState('visible'); + if (allDone) { + const timer = setTimeout(() => setFabState('fading'), 3000); + return () => clearTimeout(timer); + } + }, [total, allDone]); + + useEffect(() => { + if (fabState !== 'fading') return; + const timer = setTimeout(() => setFabState('hidden'), 500); + return () => clearTimeout(timer); + }, [fabState]); + + if (fabState === 'hidden') return null; + + const size = 48; + const strokeWidth = 3; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference * (1 - pct); + + return ( + + ); +} diff --git a/src/components/TaskProgress.tsx b/src/components/TaskProgress.tsx deleted file mode 100644 index 765d0bf..0000000 --- a/src/components/TaskProgress.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from 'react'; -import { Progress } from './ui/progress'; -import { Circle, CircleDot, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'; - -type TodoItem = { id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }; - -export function TaskProgress({ input }: { input: any }) { - const [collapsed, setCollapsed] = useState(false); - const tasks: TodoItem[] = input?.tasks || input?.todos || []; - if (tasks.length === 0) return null; - const completed = tasks.filter((t) => t.status === 'completed').length; - const pct = Math.round((completed / tasks.length) * 100); - - return ( -
- - - {!collapsed && ( -
- {tasks.map((task) => ( -
- - {task.status === 'completed' ? ( - - ) : task.status === 'in_progress' ? ( - - ) : ( - - )} - - - {task.content} - -
- ))} -
- )} -
- ); -} diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 371c1ac..550b069 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -6,6 +6,7 @@ import { api } from '../lib/api'; import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs'; import { stripMarker } from '@/lib/content-utils'; import { parseAskQuestionInput } from '@/lib/ask-question-utils'; +import { useTaskState } from './useTaskState'; export type ChatMessage = { id?: string; @@ -153,6 +154,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded'); const [historyReview, setHistoryReview] = useState(null); + const { taskSnapshot, handleTaskState, resetTasks } = useTaskState(); const queuedRef = useRef(null); const streamingRef = useRef(false); @@ -177,6 +179,13 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? } }, []); + const enterStreaming = useCallback(() => { + if (!streamingRef.current) setInterrupted(false); + setStreaming(true); + setPendingResponse(true); + streamingRef.current = true; + }, []); + // --- WebSocket Message Handler --- const handleWsMessage = useCallback((msg: any) => { switch (msg.type) { @@ -196,6 +205,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? } else { if (msg.permissionMode) setPermissionMode(msg.permissionMode); } + resetTasks(); break; case WS.CLIENT_ID: @@ -399,22 +409,22 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? break; case WS.SESSION_STATE: - if (msg.streaming) { - if (!streamingRef.current) { - setInterrupted(false); - } - setStreaming(true); - setPendingResponse(true); - streamingRef.current = true; - } + if (msg.streaming) enterStreaming(); break; - // Full history load on reconnection (replaces, not appends) case WS.HISTORY_LOAD: if (msg.messages && Array.isArray(msg.messages)) { setMessages(convertMessages(msg.messages)); } - setPendingResponse(false); + if (msg.streaming) { + enterStreaming(); + } else { + setStreaming(false); + setPendingResponse(false); + setStreamingText(''); + setThinkingStatus(null); + streamingRef.current = false; + } break; case WS.STATUS_UPDATE: @@ -464,8 +474,12 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? streamingRef.current = false; console.error('Server error:', msg.error); break; + + case WS.TASK_STATE: + handleTaskState({ tasks: msg.tasks, completed: msg.completed, total: msg.total }); + break; } - }, [drainQueue]); + }, [drainQueue, enterStreaming, handleTaskState, resetTasks]); // --- WebSocket Connection --- useEffect(() => { @@ -645,6 +659,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? queuedMessage, clearQueuedMessage, activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel, historyReview, setHistoryReview, + taskSnapshot, sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort, updateModel, updatePermissionMode, updateAdapter, }; diff --git a/src/hooks/useTaskState.ts b/src/hooks/useTaskState.ts new file mode 100644 index 0000000..fd0d633 --- /dev/null +++ b/src/hooks/useTaskState.ts @@ -0,0 +1,20 @@ +import { useState, useCallback } from 'react'; +import type { AggregatedTask, TaskSnapshot } from '../../server/stores/task-aggregator'; + +export type { AggregatedTask, TaskSnapshot }; + +const EMPTY_SNAPSHOT: TaskSnapshot = { tasks: [], completed: 0, total: 0 }; + +export function useTaskState() { + const [taskSnapshot, setTaskSnapshot] = useState(EMPTY_SNAPSHOT); + + const handleTaskState = useCallback((msg: TaskSnapshot) => { + setTaskSnapshot(msg); + }, []); + + const resetTasks = useCallback(() => { + setTaskSnapshot(EMPTY_SNAPSHOT); + }, []); + + return { taskSnapshot, handleTaskState, resetTasks }; +} diff --git a/src/hooks/useVisualViewport.ts b/src/hooks/useVisualViewport.ts new file mode 100644 index 0000000..cc1e8cd --- /dev/null +++ b/src/hooks/useVisualViewport.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, useRef } from 'react'; + +/** + * Tracks window.visualViewport.height to work around iOS PWA + * keyboard resize bugs where `dvh` units don't update correctly + * after the virtual keyboard dismisses. + * + * Also forces window.scrollTo(0, 0) when the keyboard closes, + * because iOS standalone PWA may leave the document scrolled up + * after keyboard dismissal, creating a gap at the bottom. + * + * Returns the current visual viewport height in pixels, or + * undefined on platforms that don't support the API (falls back to CSS dvh). + */ +export function useVisualViewport(): number | undefined { + const [height, setHeight] = useState(() => + typeof window !== 'undefined' && window.visualViewport + ? window.visualViewport.height + : undefined, + ); + const prevHeight = useRef(height); + + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + + const KEYBOARD_CLOSE_THRESHOLD = 50; + const update = () => { + const h = vv.height; + if (prevHeight.current && h > prevHeight.current + KEYBOARD_CLOSE_THRESHOLD) { + window.scrollTo(0, 0); + } + if (h !== prevHeight.current) { + prevHeight.current = h; + setHeight(h); + } + }; + + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + return () => { + vv.removeEventListener('resize', update); + vv.removeEventListener('scroll', update); + }; + }, []); + + return height; +} diff --git a/src/lib/ws-types.ts b/src/lib/ws-types.ts index c2d478d..719384c 100644 --- a/src/lib/ws-types.ts +++ b/src/lib/ws-types.ts @@ -37,6 +37,8 @@ export const WS = { // Cross-AI Review REVIEW_STARTED: 'review-started', REVIEW_ENDED: 'review-ended', + // Task Progress + TASK_STATE: 'task-state', } as const; /** diff --git a/src/lib/ws.ts b/src/lib/ws.ts index 461724d..6cb1689 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -5,6 +5,9 @@ export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecti type MessageHandler = (msg: any) => void; type StatusHandler = (status: WsStatus) => void; +/** Minimum hidden duration (ms) before forcing reconnect on visibility change. */ +const VISIBILITY_RECONNECT_THRESHOLD = 3000; + export class WsClient { private ws: WebSocket | null = null; private url: string; @@ -15,6 +18,8 @@ export class WsClient { private shouldReconnect = true; private activeSessionId: string | null = null; private activeAdapter: string | null = null; + private visibilityHandler: (() => void) | null = null; + private hiddenSince: number | null = null; constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; @@ -46,7 +51,9 @@ export class WsClient { if (msg.adapter) this.activeAdapter = msg.adapter; } this.onMessage(msg); - } catch {} + } catch (err) { + console.error('[ws] Failed to parse message:', err); + } }; this.ws.onclose = () => { @@ -61,6 +68,8 @@ export class WsClient { this.ws.onerror = () => { this.ws?.close(); }; + + this._startVisibilityWatch(); } send(msg: any) { @@ -74,9 +83,50 @@ export class WsClient { this.activeAdapter = adapter || null; } + /** Force close and reconnect immediately (reset backoff). */ + forceReconnect() { + if (!this.shouldReconnect) return; + this.reconnectDelay = 1000; + this.hiddenSince = null; + const old = this.ws; + this.ws = null; + if (old) { + old.onclose = null; + old.onerror = null; + old.onmessage = null; + old.close(); + } + this.connect(); + } + disconnect() { this.shouldReconnect = false; + this._stopVisibilityWatch(); this.ws?.close(); this.ws = null; } + + private _startVisibilityWatch() { + if (this.visibilityHandler) return; + this.visibilityHandler = () => { + if (document.visibilityState === 'hidden') { + this.hiddenSince = Date.now(); + } else if (document.visibilityState === 'visible' && this.shouldReconnect) { + const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0; + this.hiddenSince = null; + // Only force reconnect if page was hidden long enough for the WS to go stale + if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) { + this.forceReconnect(); + } + } + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + } + + private _stopVisibilityWatch() { + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); + this.visibilityHandler = null; + } + } }