diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b4f28459..64a01ac1 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,6 +10,7 @@ import { useEventStream } from '@/hooks/useEventStream'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; +import { useServerSessionStatus } from '@/hooks/useServerSessionStatus'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; @@ -158,6 +159,10 @@ function App({ apis }: AppProps) { useEventStream(); + // Server-authoritative session status polling + // Replaces SSE-dependent status updates with reliable HTTP polling + useServerSessionStatus(); + usePushVisibilityBeacon(); useRouter(); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 7adbb40f..46314faf 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -29,6 +29,7 @@ import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; +import { MobileSessionStatusBar } from './MobileSessionStatusBar'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; @@ -1750,7 +1751,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo )}
= ({ onOpenSettings, scrollToBo )}
- + {/* Mobile Session Status Bar - 在输入框上方 */} + {isMobile && } + ); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx new file mode 100644 index 00000000..75053f83 --- /dev/null +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -0,0 +1,499 @@ +import React from 'react'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { cn } from '@/lib/utils'; +import { getAgentColor } from '@/lib/agentColors'; +import { RiLoader4Line } from '@remixicon/react'; + +interface MobileSessionStatusBarProps { + onSessionSwitch?: (sessionId: string) => void; +} + +interface SessionWithStatus extends Session { + _statusType?: 'busy' | 'retry' | 'idle'; + _hasRunningChildren?: boolean; + _runningChildrenCount?: number; + _childIndicators?: Array<{ session: Session; isRunning: boolean }>; +} + +function useSessionGrouping( + sessions: Session[], + sessionStatus: Map | undefined, + sessionAttentionStates: Map | undefined +) { + const parentChildMap = React.useMemo(() => { + const map = new Map(); + const allIds = new Set(sessions.map((s) => s.id)); + + sessions.forEach((session) => { + const parentID = (session as { parentID?: string }).parentID; + if (parentID && allIds.has(parentID)) { + map.set(parentID, [...(map.get(parentID) || []), session]); + } + }); + return map; + }, [sessions]); + + const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => { + const status = sessionStatus?.get(sessionId); + if (status?.type === 'busy' || status?.type === 'retry') return status.type; + return 'idle'; + }, [sessionStatus]); + + const hasRunningChildren = React.useCallback((sessionId: string): boolean => { + const children = parentChildMap.get(sessionId) || []; + return children.some((child) => getStatusType(child.id) !== 'idle'); + }, [parentChildMap, getStatusType]); + + const getRunningChildrenCount = React.useCallback((sessionId: string): number => { + const children = parentChildMap.get(sessionId) || []; + return children.filter((child) => getStatusType(child.id) !== 'idle').length; + }, [parentChildMap, getStatusType]); + + const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => { + const children = parentChildMap.get(sessionId) || []; + return children + .filter((child) => getStatusType(child.id) !== 'idle') + .map((child) => ({ session: child, isRunning: true })) + .slice(0, 3); + }, [parentChildMap, getStatusType]); + + const processedSessions = React.useMemo(() => { + const topLevel = sessions.filter((session) => { + const parentID = (session as { parentID?: string }).parentID; + return !parentID || !new Set(sessions.map((s) => s.id)).has(parentID); + }); + + const running: SessionWithStatus[] = []; + const viewed: SessionWithStatus[] = []; + + topLevel.forEach((session) => { + const statusType = getStatusType(session.id); + const hasRunning = hasRunningChildren(session.id); + const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false; + + const enriched: SessionWithStatus = { + ...session, + _statusType: statusType, + _hasRunningChildren: hasRunning, + _runningChildrenCount: getRunningChildrenCount(session.id), + _childIndicators: getChildIndicators(session.id), + }; + + if (statusType !== 'idle' || hasRunning) { + running.push(enriched); + } else if (attention) { + running.push(enriched); + } else { + viewed.push(enriched); + } + }); + + const sortByUpdated = (a: Session, b: Session) => { + const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0; + const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0; + return bTime - aTime; + }; + + running.sort(sortByUpdated); + viewed.sort(sortByUpdated); + + return [...running, ...viewed]; + }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]); + + const totalRunning = processedSessions.reduce((sum, s) => { + const selfRunning = s._statusType !== 'idle' ? 1 : 0; + return sum + selfRunning + (s._runningChildrenCount ?? 0); + }, 0); + + const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length; + + return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; +} + +function useSessionHelpers( + agents: Array<{ name: string }>, + sessionStatus: Map | undefined, + sessionAttentionStates: Map | undefined +) { + const getSessionAgentName = React.useCallback((session: Session): string => { + const agent = (session as { agent?: string }).agent; + if (agent) return agent; + + const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id); + if (sessionAgentSelection) return sessionAgentSelection; + + return agents[0]?.name ?? 'agent'; + }, [agents]); + + const getSessionTitle = React.useCallback((session: Session): string => { + const title = session.title; + if (title && title.trim()) return title; + return 'New session'; + }, []); + + const isRunning = React.useCallback((sessionId: string): boolean => { + const status = sessionStatus?.get(sessionId); + return status?.type === 'busy' || status?.type === 'retry'; + }, [sessionStatus]); + + // Use server-authoritative attention state instead of local activity state + const needsAttention = React.useCallback((sessionId: string): boolean => { + return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false; + }, [sessionAttentionStates]); + + return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; +} + +function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) { + if (isRunning) { + return ; + } + if (needsAttention) { + return
; + } + return
; +} + +function RunningIndicator({ count }: { count: number }) { + if (count === 0) return null; + return ( + + + {count} running + + ); +} + +function UnreadIndicator({ count }: { count: number }) { + if (count === 0) return null; + return ( + +
+ {count} unread + + ); +} + +function SessionItem({ + session, + isCurrent, + getSessionAgentName, + getSessionTitle, + onClick, + needsAttention +}: { + session: SessionWithStatus; + isCurrent: boolean; + getSessionAgentName: (s: Session) => string; + getSessionTitle: (s: Session) => string; + onClick: () => void; + needsAttention: (sessionId: string) => boolean; +}) { + const agentName = getSessionAgentName(session); + const agentColor = getAgentColor(agentName); + const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0); + + return ( + + ); +} + +function SessionStatusHeader({ + currentSessionTitle, + runningCount, + unreadCount, + onToggle +}: { + currentSessionTitle: string; + runningCount: number; + unreadCount: number; + onToggle: () => void; +}) { + const hasActivity = runningCount > 0 || unreadCount > 0; + + return ( + + ); +} + +function CollapsedView({ + runningCount, + unreadCount, + currentSessionTitle, + onToggle, + onNewSession +}: { + runningCount: number; + unreadCount: number; + currentSessionTitle: string; + onToggle: () => void; + onNewSession: () => void; +}) { + return ( +
+
+ +
+ +
+ ); +} + +function ExpandedView({ + sessions, + currentSessionId, + runningCount, + unreadCount, + currentSessionTitle, + isExpanded, + onToggleCollapse, + onToggleExpand, + onNewSession, + onSessionClick, + getSessionAgentName, + getSessionTitle, + needsAttention +}: { + sessions: SessionWithStatus[]; + currentSessionId: string; + runningCount: number; + unreadCount: number; + currentSessionTitle: string; + isExpanded: boolean; + onToggleCollapse: () => void; + onToggleExpand: () => void; + onNewSession: () => void; + onSessionClick: (id: string) => void; + getSessionAgentName: (s: Session) => string; + getSessionTitle: (s: Session) => string; + needsAttention: (sessionId: string) => boolean; +}) { + const containerRef = React.useRef(null); + const [collapsedHeight, setCollapsedHeight] = React.useState(null); + const [hasMeasured, setHasMeasured] = React.useState(false); + + React.useEffect(() => { + if (containerRef.current && !hasMeasured && !isExpanded) { + setCollapsedHeight(containerRef.current.offsetHeight); + setHasMeasured(true); + } + }, [hasMeasured, isExpanded]); + + const previewHeight = collapsedHeight ?? undefined; + const displaySessions = hasMeasured || isExpanded ? sessions : sessions.slice(0, 3); + + return ( +
+
+
+ +
+
+ + +
+
+ +
+ {displaySessions.map((session) => ( + onSessionClick(session.id)} + needsAttention={needsAttention} + /> + ))} +
+
+ ); +} + +export const MobileSessionStatusBar: React.FC = ({ + onSessionSwitch, +}) => { + const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessionStatus = useSessionStore((state) => state.sessionStatus); + const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); + const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const createSession = useSessionStore((state) => state.createSession); + const agents = useConfigStore((state) => state.agents); + const { isMobile, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); + const [isExpanded, setIsExpanded] = React.useState(false); + + const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates); + const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates); + + const currentSession = sessions.find((s) => s.id === currentSessionId); + const currentSessionTitle = currentSession ? getSessionTitle(currentSession) : 'New session'; + + if (!isMobile || totalCount === 0) { + return null; + } + + const handleSessionClick = (sessionId: string) => { + setCurrentSession(sessionId); + onSessionSwitch?.(sessionId); + setIsExpanded(false); + }; + + const handleCreateSession = async () => { + const newSession = await createSession(); + if (newSession) { + setCurrentSession(newSession.id); + onSessionSwitch?.(newSession.id); + } + }; + + if (isMobileSessionStatusBarCollapsed) { + return ( + setIsMobileSessionStatusBarCollapsed(false)} + onNewSession={handleCreateSession} + /> + ); + } + + return ( + { + setIsMobileSessionStatusBarCollapsed(true); + setIsExpanded(false); + }} + onToggleExpand={() => setIsExpanded(!isExpanded)} + onNewSession={handleCreateSession} + onSessionClick={handleSessionClick} + getSessionAgentName={getSessionAgentName} + getSessionTitle={getSessionTitle} + needsAttention={needsAttention} + /> + ); +}; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 44a87000..9c779be7 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -2,6 +2,7 @@ import React from 'react'; import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useMessageStore } from '@/stores/messageStore'; import { getActiveSessionWindow } from '@/stores/types/sessionTypes'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; @@ -16,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore'; import { useContextStore } from '@/stores/contextStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { isDesktopLocalOriginActive } from '@/lib/desktop'; +import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus'; interface EventData { type: string; @@ -392,8 +394,7 @@ export const useEventStream = () => { [bootstrapState] ); - const sessionStatusLastRefreshAtRef = React.useRef(0); - const sessionStatusRefreshInFlightRef = React.useRef | null>(null); + const currentSessionIdRef = React.useRef(currentSessionId); const previousSessionIdRef = React.useRef(null); const previousSessionDirectoryRef = React.useRef(null); @@ -464,7 +465,6 @@ export const useEventStream = () => { [applySessionMetadata, getWorktreeMetadata] ); - type SessionStatusPayload = { type: 'idle' | 'busy' | 'retry'; attempt?: number; @@ -483,6 +483,9 @@ export const useEventStream = () => { const prevType = storeStatus?.type ?? 'idle'; const nextType = status?.type ?? 'idle'; + // Note: needs_attention logic is now handled by the server + // Server maintains authoritative state based on view tracking and message events + if (prevType !== nextType) { try { console.info('[SESSION-STATUS]', { @@ -550,125 +553,18 @@ export const useEventStream = () => { const next = new Map(useSessionStore.getState().sessionStatus ?? new Map()); if (nextType === 'idle') { - next.delete(sessionId); + next.set(sessionId, { ...status, confirmedAt: Date.now() }); } else { - next.set(sessionId, status); + const existing = next.get(sessionId); + if (existing?.confirmedAt) { + next.set(sessionId, { ...status, confirmedAt: existing.confirmedAt }); + } else { + next.set(sessionId, status); + } } useSessionStore.setState({ sessionStatus: next }); }, []); - const refreshSessionStatus = React.useCallback(async () => { - const now = Date.now(); - if (sessionStatusRefreshInFlightRef.current) { - return sessionStatusRefreshInFlightRef.current; - } - if (now - sessionStatusLastRefreshAtRef.current < 1500) { - return; - } - sessionStatusLastRefreshAtRef.current = now; - - const applyStatusMap = (statusMap: Record) => { - const observed = new Set(); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const knownSessionIds = new Set(currentSessions.map((session) => session.id)); - - for (const [sessionId, raw] of Object.entries(statusMap)) { - if (!sessionId || !raw) continue; - observed.add(sessionId); - const typeRaw = raw.type; - const status: SessionStatusPayload = - typeRaw === 'retry' - ? { - type: 'retry', - attempt: (raw as { attempt?: unknown }).attempt as number | undefined, - message: (raw as { message?: unknown }).message as string | undefined, - next: (raw as { next?: unknown }).next as number | undefined, - } - : typeRaw === 'busy' || typeRaw === 'cooldown' - ? { type: 'busy' } - : { type: 'idle' }; - updateSessionStatus(sessionId, status, 'poll:/session/status'); - } - - // OpenCode's /session/status may omit idle sessions (returns only busy/retry). - // Treat missing entries as idle to avoid sessions getting stuck "working". - const currentStatuses = useSessionStore.getState().sessionStatus; - if (!currentStatuses) return; - - for (const [sessionId, status] of currentStatuses.entries()) { - if (!knownSessionIds.has(sessionId)) continue; - if ((status.type === 'busy' || status.type === 'retry') && !observed.has(sessionId)) { - updateSessionStatus(sessionId, { type: 'idle' }, 'poll:missing->idle'); - } - } - }; - - const task = (async (): Promise => { - try { - // OpenCode global session status (busy/retry only; idle omitted) - const globalStatusMap = await opencodeClient.getGlobalSessionStatus(); - if (globalStatusMap && Object.keys(globalStatusMap).length > 0) { - applyStatusMap(globalStatusMap); - return; - } - - const directories = new Set(); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - for (const session of currentSessions) { - const directory = resolveSessionDirectoryForStatus(session.id); - if (directory) directories.add(directory); - } - - const effective = normalizeDirectory(effectiveDirectory ?? null); - if (effective) directories.add(effective); - - const queries = Array.from(directories); - if (queries.length === 0) { - // Fall back to scoped status for whatever the OpenCode client currently tracks. - const scoped = await opencodeClient.getSessionStatus(); - if (scoped) { - applyStatusMap(scoped); - } - return; - } - - const results = await Promise.allSettled( - queries.map((directory) => opencodeClient.getSessionStatusForDirectory(directory)) - ); - - const merged: Record = {}; - for (const result of results) { - if (result.status !== 'fulfilled' || !result.value) continue; - Object.assign(merged, result.value); - } - - if (Object.keys(merged).length === 0) { - const hasActiveStatuses = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some( - (status) => status?.type === 'busy' || status?.type === 'retry' - ); - - if (hasActiveStatuses) { - const healthy = await opencodeClient.checkHealth().catch(() => false); - if (!healthy) { - return; - } - } - } - - applyStatusMap(merged); - } catch { - // ignored - } - })().finally(() => { - sessionStatusRefreshInFlightRef.current = null; - }); - - sessionStatusRefreshInFlightRef.current = task; - return task; - }, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionStatus]); - React.useEffect(() => { const nextSessionId = currentSessionId ?? null; const prevSessionId = previousSessionIdRef.current; @@ -677,13 +573,13 @@ export const useEventStream = () => { if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) { if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) { - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); } } previousSessionIdRef.current = nextSessionId; previousSessionDirectoryRef.current = nextDirectory; - }, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]); + }, [currentSessionId, resolveSessionDirectoryForStatus]); const handleEvent = React.useCallback((event: EventData) => { lastEventTimestampRef.current = Date.now(); @@ -766,6 +662,47 @@ export const useEventStream = () => { } break; + case 'openchamber:session-status': + { + const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null; + const status = typeof props.status === 'string' ? props.status : null; + const needsAttention = typeof props.needsAttention === 'boolean' ? props.needsAttention : false; + const timestamp = typeof props.timestamp === 'number' ? props.timestamp : Date.now(); + + if (sessionId && status) { + // Update session status + if (status === 'busy') { + updateSessionStatus(sessionId, { type: 'busy' }, 'sse:openchamber:session-status'); + } else if (status === 'retry') { + const metadata = (typeof props.metadata === 'object' && props.metadata !== null) ? props.metadata as Record : {}; + updateSessionStatus(sessionId, { + type: 'retry', + attempt: typeof metadata.attempt === 'number' ? metadata.attempt : undefined, + message: typeof metadata.message === 'string' ? metadata.message : undefined, + next: typeof metadata.next === 'number' ? metadata.next : undefined, + }, 'sse:openchamber:session-status'); + } else { + updateSessionStatus(sessionId, { type: 'idle' }, 'sse:openchamber:session-status'); + } + + // Update attention state in the same update to ensure atomicity + const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); + const newAttentionStates = new Map(currentAttentionStates); + const existing = newAttentionStates.get(sessionId); + + newAttentionStates.set(sessionId, { + needsAttention, + lastStatusChangeAt: timestamp, + lastUserMessageAt: existing?.lastUserMessageAt ?? null, + status: status as 'idle' | 'busy' | 'retry', + isViewed: existing?.isViewed ?? false, + }); + + useSessionStore.setState({ sessionAttentionStates: newAttentionStates }); + } + } + break; + case 'message.part.updated': { const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null; if (!part) break; @@ -938,6 +875,18 @@ export const useEventStream = () => { trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role }); if ((messageExt as { role?: unknown }).role === 'user') { + // Update lastUserMessageAt in session memory state + const { sessionMemoryState } = useMessageStore.getState(); + const currentMemory = sessionMemoryState.get(sessionId); + if (currentMemory) { + const newMemoryState = new Map(sessionMemoryState); + newMemoryState.set(sessionId, { + ...currentMemory, + lastUserMessageAt: Date.now(), + }); + useMessageStore.setState({ sessionMemoryState: newMemoryState }); + } + const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; const existingUserMessage = getMessageFromStore(sessionId, messageId); @@ -1269,6 +1218,8 @@ export const useEventStream = () => { } completeStreamingMessage(sessionId, messageId); + updateSessionStatus(sessionId, { type: 'idle' }, 'sse:message.updated.completed'); + // Removed: void refreshSessionStatus(); const rawMessageSessionId = (message as { sessionID?: string }).sessionID; const messageSessionId: string = @@ -1345,6 +1296,9 @@ export const useEventStream = () => { ? (props.messageID as string) : null; + if (sessionId) { + updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.abort'); + } if (sessionId && messageId) { completeStreamingMessage(sessionId, messageId); } @@ -1533,11 +1487,12 @@ export const useEventStream = () => { applySessionMetadata, trackMessage, reportMessage, - updateSessionStatus, + updateSession, removeSessionFromStore, bootstrapState, - effectiveDirectory + effectiveDirectory, + updateSessionStatus, ]); const shouldHoldConnection = React.useCallback(() => { @@ -1627,10 +1582,11 @@ export const useEventStream = () => { lastEventTimestampRef.current = Date.now(); publishStatus('connected', null); checkConnection(); + triggerSessionStatusPoll(); // Always refresh session status on connect to detect any // already-running sessions (e.g., started via CLI before UI opened) - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); if (shouldRefresh) { void bootstrapState('sse_reconnected'); @@ -1713,7 +1669,7 @@ export const useEventStream = () => { requestSessionMetadataRefresh, handleEvent, effectiveDirectory, - refreshSessionStatus, + debugConnectionState, bootstrapState ]); @@ -1800,7 +1756,8 @@ export const useEventStream = () => { requestSessionMetadataRefresh(sessionId); } - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); + triggerSessionStatusPoll(); publishStatus('connecting', 'Resuming stream'); startStream({ resetAttempts: true }); } @@ -1825,7 +1782,8 @@ export const useEventStream = () => { requestSessionMetadataRefresh(sessionId); scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow()); } - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); + triggerSessionStatusPoll(); publishStatus('connecting', 'Resuming stream'); startStream({ resetAttempts: true }); @@ -1837,6 +1795,7 @@ export const useEventStream = () => { onlineStatusRef.current = true; maybeBootstrapIfStale('network_restored'); if (pendingResumeRef.current || !unsubscribeRef.current) { + triggerSessionStatusPoll(); publishStatus('connecting', 'Network restored'); startStream({ resetAttempts: true }); } @@ -1865,7 +1824,8 @@ export const useEventStream = () => { void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow()); requestSessionMetadataRefresh(sessionId); } - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); + triggerSessionStatusPoll(); startStream({ resetAttempts: true }); } }; @@ -1899,7 +1859,7 @@ export const useEventStream = () => { ); if (hasBusySessions) { - void refreshSessionStatus(); + // Removed: void refreshSessionStatus(); } if (now - lastEventTimestampRef.current > 45000) { Promise.resolve().then(async () => { @@ -1974,8 +1934,8 @@ export const useEventStream = () => { scheduleReconnect, loadMessages, requestSessionMetadataRefresh, - updateSessionStatus, - refreshSessionStatus, + + shouldHoldConnection, loadSessions, maybeBootstrapIfStale, diff --git a/packages/ui/src/hooks/useServerSessionStatus.ts b/packages/ui/src/hooks/useServerSessionStatus.ts new file mode 100644 index 00000000..d0aa995b --- /dev/null +++ b/packages/ui/src/hooks/useServerSessionStatus.ts @@ -0,0 +1,243 @@ +import React from 'react'; +import { useSessionStore } from '@/stores/useSessionStore'; + +interface SessionState { + status: 'idle' | 'busy' | 'retry'; + lastUpdateAt: number; + metadata?: { + attempt?: number; + message?: string; + next?: number; + }; +} + +interface SessionAttentionState { + needsAttention: boolean; + lastUserMessageAt: number | null; + lastStatusChangeAt: number; + status: 'idle' | 'busy' | 'retry'; + isViewed: boolean; +} + +interface ServerSnapshotResponse { + statusSessions: Record; + attentionSessions: Record; + serverTime: number; +} + +const IMMEDIATE_POLL_DELAY_MS = 500; // 500ms for immediate poll after notification + +// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll +let triggerImmediatePollRef: (() => void) | null = null; + +// Global function to trigger immediate poll from outside React +export const triggerSessionStatusPoll = () => { + if (triggerImmediatePollRef) { + triggerImmediatePollRef(); + } +}; + +/** + * Hook to synchronize session status and attention state from server. + * + * Architecture: server maintains authoritative state, client applies snapshots. + * SSE remains the primary transport; snapshots repair missed updates. + */ +export function useServerSessionStatus() { + const isSyncingRef = React.useRef(false); + const lastSyncAtRef = React.useRef(0); + const timeoutRef = React.useRef(null); + + const fetchSessionStatus = React.useCallback(async (immediate = false) => { + const now = Date.now(); + if (!immediate && now - lastSyncAtRef.current < 1000) { + return; + } + + // Prevent concurrent syncs + if (isSyncingRef.current) { + return; + } + + isSyncingRef.current = true; + lastSyncAtRef.current = now; + + try { + const snapshotResponse = await fetch('/api/sessions/snapshot', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!snapshotResponse.ok) { + console.warn('[useServerSessionStatus] Failed to fetch session snapshot:', snapshotResponse.status); + return; + } + + const snapshotData: ServerSnapshotResponse = await snapshotResponse.json(); + const statusSessions = snapshotData.statusSessions ?? {}; + const attentionSessions = snapshotData.attentionSessions ?? {}; + + // Update the session store with server state + const currentStatuses = useSessionStore.getState().sessionStatus || new Map(); + let newStatuses: Map | null = null; + const ensureStatusesMap = () => { + if (!newStatuses) { + newStatuses = new Map(currentStatuses); + } + return newStatuses; + }; + + for (const [sessionId, state] of Object.entries(statusSessions)) { + const existing = currentStatuses.get(sessionId); + const hasChanged = + !existing || + existing.type !== state.status || + existing.attempt !== state.metadata?.attempt || + existing.message !== state.metadata?.message || + existing.next !== state.metadata?.next || + existing.confirmedAt !== state.lastUpdateAt; + + // Only update if server state is different + if (hasChanged) { + ensureStatusesMap().set(sessionId, { + type: state.status, + confirmedAt: state.lastUpdateAt, + attempt: state.metadata?.attempt, + message: state.metadata?.message, + next: state.metadata?.next, + }); + } + } + + // Check for sessions that are no longer in server state (treat as idle) + for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) { + if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') && + !statusSessions[sessionId]) { + // Session was busy but not in server state anymore -> mark as idle + ensureStatusesMap().set(sessionId, { + type: 'idle', + confirmedAt: Date.now(), + }); + } + } + + // Update attention state from server + const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); + let newAttentionStates: Map | null = null; + const ensureAttentionMap = () => { + if (!newAttentionStates) { + newAttentionStates = new Map(currentAttentionStates); + } + return newAttentionStates; + }; + let attentionStatesChanged = false; + + for (const [sessionId, attentionState] of Object.entries(attentionSessions)) { + const existing = currentAttentionStates.get(sessionId); + const serverState = attentionState as SessionAttentionState; + const hasChanged = + !existing || + existing.needsAttention !== serverState.needsAttention || + existing.lastUserMessageAt !== serverState.lastUserMessageAt || + existing.lastStatusChangeAt !== serverState.lastStatusChangeAt || + existing.status !== serverState.status || + existing.isViewed !== serverState.isViewed; + + if (hasChanged) { + ensureAttentionMap().set(sessionId, serverState); + attentionStatesChanged = true; + } + } + + // Remove attention states for sessions that no longer exist + for (const sessionId of (newAttentionStates ?? currentAttentionStates).keys()) { + const inStatus = !!statusSessions[sessionId]; + const inAttention = !!attentionSessions[sessionId]; + if (!inStatus && !inAttention) { + ensureAttentionMap().delete(sessionId); + attentionStatesChanged = true; + } + } + + // Only update store if something actually changed + const statusChanged = newStatuses !== null; + if (statusChanged || attentionStatesChanged) { + useSessionStore.setState({ + ...(statusChanged && newStatuses ? { sessionStatus: newStatuses } : {}), + ...(attentionStatesChanged && newAttentionStates ? { sessionAttentionStates: newAttentionStates } : {}), + }); + } + + if (process.env.NODE_ENV === 'development') { + console.debug('[useServerSessionStatus] Updated session statuses from server:', { + statusCount: Object.keys(statusSessions).length, + attentionCount: Object.keys(attentionSessions).length, + serverTime: snapshotData.serverTime, + }); + } + } catch (error) { + console.warn('[useServerSessionStatus] Error fetching session status:', error); + } finally { + isSyncingRef.current = false; + } + }, []); + + // Initial snapshot sync on mount + React.useEffect(() => { + void fetchSessionStatus(true); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [fetchSessionStatus]); + + // Sync snapshot when tab becomes visible + React.useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Small delay to let the browser settle + timeoutRef.current = setTimeout(() => { + void fetchSessionStatus(true); + }, 100); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [fetchSessionStatus]); + + // Function to trigger immediate snapshot sync from external modules + const triggerImmediatePoll = React.useCallback(() => { + // Clear any pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Schedule immediate sync with small delay to batch rapid calls + timeoutRef.current = setTimeout(() => { + void fetchSessionStatus(true); + }, IMMEDIATE_POLL_DELAY_MS); + }, [fetchSessionStatus]); + + // Update the ref for external access + React.useEffect(() => { + triggerImmediatePollRef = triggerImmediatePoll; + return () => { + triggerImmediatePollRef = null; + }; + }, [triggerImmediatePoll]); + + return { + fetchSessionStatus, + triggerImmediatePoll, + }; +} + +// Export ref accessor for external modules +export const getTriggerImmediatePoll = () => triggerImmediatePollRef; + +export default useServerSessionStatus; diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index 2a7c1226..f34bca07 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -8,6 +8,7 @@ import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus"; import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager"; import { useDirectoryStore } from "./useDirectoryStore"; import { useProjectsStore } from "./useProjectsStore"; +import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus"; import type { ProjectEntry } from "@/lib/api/types"; import { checkIsGitRepository } from "@/lib/gitApi"; import { streamDebugEnabled } from "@/stores/utils/streamDebug"; @@ -1237,7 +1238,26 @@ export const useSessionStore = create()( }, setCurrentSession: (id: string | null) => { + const prevSessionId = get().currentSessionId; set({ currentSessionId: id, error: null }); + + // Notify server of view state changes + // This enables server-side needs_attention tracking + if (prevSessionId && prevSessionId !== id) { + // Leaving previous session + fetch(`/api/sessions/${prevSessionId}/unview`, { method: 'POST' }) + .catch(() => { /* ignore */ }); + } + if (id) { + // Entering new session + fetch(`/api/sessions/${id}/view`, { method: 'POST' }) + .catch(() => { /* ignore */ }); + } + + // Trigger immediate poll to get latest attention states + // This prevents stale state when switching sessions + triggerSessionStatusPoll(); + const directory = opencodeClient.getDirectory() ?? null; storeSessionForDirectory(directory, id); }, @@ -1513,7 +1533,7 @@ export const useSessionStore = create()( const mergedSessions = dedupeSessionsById(persistedSessions); - return { + const mergedResult = { ...currentState, ...persistedState, sessions: mergedSessions, @@ -1527,6 +1547,7 @@ export const useSessionStore = create()( : currentState.availableWorktreesByProject, lastLoadedDirectory, }; + return mergedResult; }, } ), diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 996d0b5f..5e294474 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -35,6 +35,7 @@ export interface SessionMemoryState { hasMoreAbove?: boolean; trimmedHeadMaxId?: string; streamingCooldownUntil?: number; + lastUserMessageAt?: number; // Timestamp when user last sent a message } export interface SessionContextUsage { @@ -136,11 +137,22 @@ export interface SessionStore { // Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle). // Use as the single source of truth for "assistant working" UI. + // confirmedAt: timestamp when idle was confirmed locally (prevents race with server polling) sessionStatus?: Map< string, - { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number } + { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number } >; + // Server-authoritative session attention state + // Tracks which sessions need user attention based on server-side logic + sessionAttentionStates: Map; + userSummaryTitles: Map; pendingInputText: string | null; @@ -238,10 +250,10 @@ export interface SessionStore { updateSession: (session: Session) => void; removeSessionFromStore: (sessionId: string) => void; - revertToMessage: (sessionId: string, messageId: string) => Promise; - handleSlashUndo: (sessionId: string) => Promise; - handleSlashRedo: (sessionId: string) => Promise; - forkFromMessage: (sessionId: string, messageId: string) => Promise; - setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void; - consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null; - } + revertToMessage: (sessionId: string, messageId: string) => Promise; + handleSlashUndo: (sessionId: string) => Promise; + handleSlashRedo: (sessionId: string) => Promise; + forkFromMessage: (sessionId: string, messageId: string) => Promise; + setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void; + consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null; + } diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 23081d2d..ef50f7ac 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -99,6 +99,7 @@ export const useSessionStore = create()( abortPromptSessionId: null, abortPromptExpiresAt: null, sessionStatus: new Map(), + sessionAttentionStates: new Map(), userSummaryTitles: new Map(), pendingInputText: null, pendingInputMode: 'replace', @@ -423,6 +424,26 @@ export const useSessionStore = create()( if (currentSessionId) { setStatus(currentSessionId, 'busy'); + + const memoryState = get().sessionMemoryState.get(currentSessionId); + if (!memoryState || !memoryState.lastUserMessageAt) { + const currentMemoryState = get().sessionMemoryState; + const newMemoryState = new Map(currentMemoryState); + newMemoryState.set(currentSessionId, { + viewportAnchor: memoryState?.viewportAnchor ?? 0, + isStreaming: memoryState?.isStreaming ?? false, + lastAccessedAt: Date.now(), + backgroundMessageCount: memoryState?.backgroundMessageCount ?? 0, + lastUserMessageAt: Date.now(), + }); + set({ sessionMemoryState: newMemoryState }); + } + } + + // Notify server that user sent a message in this session + if (currentSessionId) { + fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' }) + .catch(() => { /* ignore */ }); } try { diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 398a8b91..9ca1857e 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -75,6 +75,7 @@ interface UIStore { showTerminalQuickKeysOnDesktop: boolean; persistChatDraft: boolean; + isMobileSessionStatusBarCollapsed: boolean; setTheme: (theme: 'light' | 'dark' | 'system') => void; toggleSidebar: () => void; @@ -135,6 +136,7 @@ interface UIStore { setShowTerminalQuickKeysOnDesktop: (value: boolean) => void; setNotifyOnSubtasks: (value: boolean) => void; setPersistChatDraft: (value: boolean) => void; + setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; } @@ -199,6 +201,7 @@ export const useUIStore = create()( showTerminalQuickKeysOnDesktop: false, persistChatDraft: true, + isMobileSessionStatusBarCollapsed: false, setTheme: (theme) => { set({ theme }); @@ -688,6 +691,9 @@ export const useUIStore = create()( setPersistChatDraft: (value) => { set({ persistChatDraft: value }); }, + setIsMobileSessionStatusBarCollapsed: (value) => { + set({ isMobileSessionStatusBarCollapsed: value }); + }, }), { name: 'ui-store', @@ -726,6 +732,7 @@ export const useUIStore = create()( showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, notifyOnSubtasks: state.notifyOnSubtasks, persistChatDraft: state.persistChatDraft, + isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, }) } ), diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4ee6d7b1..518c7f52 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1608,6 +1608,246 @@ const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'| const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId const SESSION_COOLDOWN_DURATION_MS = 2000; +// Complete session status tracking - source of truth for web clients +// This maintains the authoritative state, clients only cache it +const sessionStates = new Map(); // sessionId -> { +// status: 'idle'|'busy'|'retry', +// lastUpdateAt: number, +// lastEventId: string, +// metadata: { attempt?: number, message?: string, next?: number } +// } +const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +const updateSessionState = (sessionId, status, eventId, metadata = {}) => { + if (!sessionId || typeof sessionId !== 'string') return; + + const now = Date.now(); + const existing = sessionStates.get(sessionId); + + // Only update if this is a newer event (simple ordering protection) + if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) { + // Same status within 5 seconds, skip to reduce noise + return; + } + + sessionStates.set(sessionId, { + status, + lastUpdateAt: now, + lastEventId: eventId || `server-${now}`, + metadata: { ...existing?.metadata, ...metadata } + }); + + // Update attention tracking state (must be called before broadcasting) + updateSessionAttentionStatus(sessionId, status, eventId); + + // Broadcast status change to connected web clients via SSE + // This enables real-time updates without polling + // Include needsAttention in the same event to ensure atomic updates + if (uiNotificationClients.size > 0 && (!existing || existing.status !== status)) { + const state = sessionStates.get(sessionId); + const attentionState = sessionAttentionStates.get(sessionId); + for (const res of uiNotificationClients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: state.lastUpdateAt, + metadata: state.metadata, + needsAttention: attentionState?.needsAttention ?? false + } + }); + } catch { + // Client disconnected, will be cleaned up by close handler + } + } + } + + // Also update activity phases for backward compatibility + const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; + setSessionActivityPhase(sessionId, phase); +}; + +const getSessionStateSnapshot = () => { + const result = {}; + const now = Date.now(); + + for (const [sessionId, data] of sessionStates) { + // Skip very old states (session likely gone) + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; + + result[sessionId] = { + status: data.status, + lastUpdateAt: data.lastUpdateAt, + metadata: data.metadata + }; + } + + return result; +}; + +const getSessionState = (sessionId) => { + if (!sessionId) return null; + return sessionStates.get(sessionId) || null; +}; + +// Session attention tracking - authoritative source for unread/needs-attention state +// Tracks which sessions need user attention based on activity and view state +const sessionAttentionStates = new Map(); // sessionId -> { +// needsAttention: boolean, +// lastUserMessageAt: number | null, +// lastStatusChangeAt: number, +// viewedByClients: Set, +// status: 'idle' | 'busy' | 'retry' +// } +const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + +const getOrCreateAttentionState = (sessionId) => { + if (!sessionId || typeof sessionId !== 'string') return null; + + let state = sessionAttentionStates.get(sessionId); + if (!state) { + state = { + needsAttention: false, + lastUserMessageAt: null, + lastStatusChangeAt: Date.now(), + viewedByClients: new Set(), + status: 'idle' + }; + sessionAttentionStates.set(sessionId, state); + } + return state; +}; + +const updateSessionAttentionStatus = (sessionId, status, eventId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const prevStatus = state.status; + state.status = status; + state.lastStatusChangeAt = Date.now(); + + // Check if we need to mark as needsAttention + // Condition: transitioning from busy/retry to idle + user sent message + not currently viewed + // Note: The actual broadcast with needsAttention is done in updateSessionState + // to ensure both status and attention are sent in a single event + if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { + if (state.lastUserMessageAt && state.viewedByClients.size === 0) { + state.needsAttention = true; + } + } +}; + +const markSessionViewed = (sessionId, clientId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const wasNeedsAttention = state.needsAttention; + state.viewedByClients.add(clientId); + + // Clear needsAttention when viewed + if (wasNeedsAttention) { + state.needsAttention = false; + + // Broadcast attention cleared event + if (uiNotificationClients.size > 0) { + for (const res of uiNotificationClients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: Date.now(), + metadata: {}, + needsAttention: false + } + }); + } catch { + // Client disconnected + } + } + } + } +}; + +const markSessionUnviewed = (sessionId, clientId) => { + const state = sessionAttentionStates.get(sessionId); + if (!state) return; + + state.viewedByClients.delete(clientId); +}; + +const markUserMessageSent = (sessionId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + state.lastUserMessageAt = Date.now(); +}; + +const getSessionAttentionSnapshot = () => { + const result = {}; + const now = Date.now(); + + for (const [sessionId, state] of sessionAttentionStates) { + // Skip very old states + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; + + result[sessionId] = { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0 + }; + } + + return result; +}; + +const getSessionAttentionState = (sessionId) => { + if (!sessionId) return null; + const state = sessionAttentionStates.get(sessionId); + if (!state) return null; + + return { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0 + }; +}; + +const cleanupOldSessionStates = () => { + const now = Date.now(); + let cleaned = 0; + + for (const [sessionId, data] of sessionStates) { + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { + sessionStates.delete(sessionId); + cleaned++; + } + } + + // Also cleanup attention states + for (const [sessionId, state] of sessionAttentionStates) { + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { + sessionAttentionStates.delete(sessionId); + cleaned++; + } + } + + if (cleaned > 0) { + console.info(`[SessionState] Cleaned up ${cleaned} old session states`); + } +}; + +// Start periodic cleanup +setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); + const setSessionActivityPhase = (sessionId, phase) => { if (!sessionId || typeof sessionId !== 'string') return false; @@ -2373,10 +2613,11 @@ const startGlobalEventWatcher = async () => { buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - let separatorIndex; - while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) { + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex !== -1) { const block = buffer.slice(0, separatorIndex); buffer = buffer.slice(separatorIndex + 2); + separatorIndex = buffer.indexOf('\n\n'); const payload = parseSseDataPayload(block); void maybeSendPushForTrigger(payload); // Track session activity independently of UI (mirrors Tauri desktop behavior) @@ -2386,6 +2627,21 @@ const startGlobalEventWatcher = async () => { setSessionActivityPhase(activity.sessionId, activity.phase); } } + + // Update authoritative session state from OpenCode events + if (payload && payload.type === 'session.status') { + const status = payload.properties?.status; + const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId; + const eventId = payload.properties?.eventId || `sse-${Date.now()}`; + + if (typeof sessionId === 'string' && status?.type) { + updateSessionState(sessionId, status.type, eventId, { + attempt: status.attempt, + message: status.message, + next: status.next + }); + } + } } } } catch (error) { @@ -3899,6 +4155,114 @@ async function main(options = {}) { res.json(getSessionActivitySnapshot()); }); + // New authoritative session status endpoints + // Server maintains the source of truth, clients only query + + // GET /api/sessions/snapshot - Combined status + attention snapshot + app.get('/api/sessions/snapshot', (_req, res) => { + res.json({ + statusSessions: getSessionStateSnapshot(), + attentionSessions: getSessionAttentionSnapshot(), + serverTime: Date.now() + }); + }); + + // GET /api/sessions/status - Get status for all sessions + app.get('/api/sessions/status', (_req, res) => { + const snapshot = getSessionStateSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now() + }); + }); + + // GET /api/sessions/:id/status - Get status for a specific session + app.get('/api/sessions/:id/status', (req, res) => { + const sessionId = req.params.id; + const state = getSessionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no state available', + sessionId + }); + } + + res.json({ + sessionId, + ...state + }); + }); + + // Session attention tracking endpoints + // GET /api/sessions/attention - Get attention state for all sessions + app.get('/api/sessions/attention', (_req, res) => { + const snapshot = getSessionAttentionSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now() + }); + }); + + // GET /api/sessions/:id/attention - Get attention state for a specific session + app.get('/api/sessions/:id/attention', (req, res) => { + const sessionId = req.params.id; + const state = getSessionAttentionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no attention state available', + sessionId + }); + } + + res.json({ + sessionId, + ...state + }); + }); + + // POST /api/sessions/:id/view - Client reports viewing this session + app.post('/api/sessions/:id/view', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionViewed(sessionId, clientId); + + res.json({ + success: true, + sessionId, + viewed: true + }); + }); + + // POST /api/sessions/:id/unview - Client reports leaving this session + app.post('/api/sessions/:id/unview', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionUnviewed(sessionId, clientId); + + res.json({ + success: true, + sessionId, + viewed: false + }); + }); + + // POST /api/sessions/:id/message-sent - User sent a message in this session + app.post('/api/sessions/:id/message-sent', (req, res) => { + const sessionId = req.params.id; + + markUserMessageSent(sessionId); + + res.json({ + success: true, + sessionId, + messageSent: true + }); + }); + app.get('/api/openchamber/update-check', async (_req, res) => { try { const { checkForUpdates } = await import('./lib/package-manager.js'); @@ -4146,11 +4510,12 @@ async function main(options = {}) { if (done) break; buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - let separatorIndex; - while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) { + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex !== -1) { const block = buffer.slice(0, separatorIndex); buffer = buffer.slice(separatorIndex + 2); forwardBlock(block); + separatorIndex = buffer.indexOf('\n\n'); } } @@ -4270,11 +4635,12 @@ async function main(options = {}) { if (done) break; buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - let separatorIndex; - while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) { + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex !== -1) { const block = buffer.slice(0, separatorIndex); buffer = buffer.slice(separatorIndex + 2); forwardBlock(block); + separatorIndex = buffer.indexOf('\n\n'); } }