feat(session-status): mobile session quick switch with running/unread indicators (#340)
* feat(session-status): implement server-authoritative session status tracking - Add server-side session states and attention tracking with Map storage - Implement SSE event broadcasting (openchamber:session-status) - Add REST API endpoints for status/attention queries - Replace client-side polling with HTTP polling + SSE push - Track needsAttention based on user message + completion + unviewed - Add MobileSessionStatusBar for mobile UI - Handle session view/unview/message-sent state transitions - Add 24h cleanup for old session states * feat(session-status): add unread indicator to mobile session status bar * refactor(session-status): extract SessionStatusHeader component Extract the session status header into a reusable component and improve layout structure in CollapsedView and ExpandedView. * feat(session-status): optimize mobile session status bar UI * refactor(session-status): remove interval polling, use snapshot sync on reconnect/visibility - unifies session status/attention sync with existing SSE lifecycle, fixes no-op store churn bug, and drops duplicated client activity state while preserving mobile unread/running UX. --------- Co-authored-by: Jovines <jovines@qq.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Jovines
Bohdan Triapitsyn
parent
aa85e31420
commit
d5a2e49ae8
@@ -10,6 +10,7 @@ import { useEventStream } from '@/hooks/useEventStream';
|
|||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
|
import { useServerSessionStatus } from '@/hooks/useServerSessionStatus';
|
||||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
@@ -158,6 +159,10 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
useEventStream();
|
useEventStream();
|
||||||
|
|
||||||
|
// Server-authoritative session status polling
|
||||||
|
// Replaces SSE-dependent status updates with reliable HTTP polling
|
||||||
|
useServerSessionStatus();
|
||||||
|
|
||||||
usePushVisibilityBeacon();
|
usePushVisibilityBeacon();
|
||||||
|
|
||||||
useRouter();
|
useRouter();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
|||||||
import { StatusRow } from './StatusRow';
|
import { StatusRow } from './StatusRow';
|
||||||
import { MobileAgentButton } from './MobileAgentButton';
|
import { MobileAgentButton } from './MobileAgentButton';
|
||||||
import { MobileModelButton } from './MobileModelButton';
|
import { MobileModelButton } from './MobileModelButton';
|
||||||
|
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
@@ -1750,7 +1751,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible",
|
"flex flex-col relative overflow-visible overflow-hidden",
|
||||||
"border border-border/80",
|
"border border-border/80",
|
||||||
"focus-within:ring-1 focus-within:ring-primary/50"
|
"focus-within:ring-1 focus-within:ring-primary/50"
|
||||||
)}
|
)}
|
||||||
@@ -1884,8 +1885,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Mobile Session Status Bar - 在输入框上方 */}
|
||||||
|
{isMobile && <MobileSessionStatusBar />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string, { type: string }> | undefined,
|
||||||
|
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
|
||||||
|
) {
|
||||||
|
const parentChildMap = React.useMemo(() => {
|
||||||
|
const map = new Map<string, Session[]>();
|
||||||
|
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<string, { type: string }> | undefined,
|
||||||
|
sessionAttentionStates: Map<string, { needsAttention: boolean }> | 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 <RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />;
|
||||||
|
}
|
||||||
|
if (needsAttention) {
|
||||||
|
return <div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />;
|
||||||
|
}
|
||||||
|
return <div className="h-1.5 w-1.5 rounded-full border border-[var(--surface-mutedForeground)]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunningIndicator({ count }: { count: number }) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-[var(--status-info)]">
|
||||||
|
<RiLoader4Line className="h-3 w-3 animate-spin" />
|
||||||
|
{count} running
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnreadIndicator({ count }: { count: number }) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-[var(--status-error)]">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
|
||||||
|
{count} unread
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-0.5 px-1.5 py-px text-left transition-colors",
|
||||||
|
"hover:bg-[var(--interactive-hover)] active:bg-[var(--interactive-selection)]",
|
||||||
|
isCurrent && "bg-[var(--interactive-selection)]/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-3 flex items-center justify-center">
|
||||||
|
<StatusIndicator
|
||||||
|
isRunning={session._statusType !== 'idle'}
|
||||||
|
needsAttention={needsAttention(session.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: `var(${agentColor.var})` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={cn(
|
||||||
|
"text-[13px] truncate leading-tight",
|
||||||
|
isCurrent ? "text-[var(--interactive-selection-foreground)] font-medium" : "text-[var(--surface-foreground)]"
|
||||||
|
)}>
|
||||||
|
{getSessionTitle(session)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{(session._childIndicators?.length || 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-0.5 text-[var(--surface-mutedForeground)]">
|
||||||
|
<span className="text-[10px]">[</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{session._childIndicators!.map(({ session: child }) => {
|
||||||
|
const childColor = getAgentColor(getSessionAgentName(child));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
title={`Sub-session: ${getSessionTitle(child)}`}
|
||||||
|
>
|
||||||
|
<RiLoader4Line
|
||||||
|
className="h-2.5 w-2.5 animate-spin"
|
||||||
|
style={{ color: `var(${childColor.var})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{extraCount > 0 && (
|
||||||
|
<span className="text-[10px] text-[var(--surface-mutedForeground)]">
|
||||||
|
+{extraCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px]">]</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionStatusHeader({
|
||||||
|
currentSessionTitle,
|
||||||
|
runningCount,
|
||||||
|
unreadCount,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
currentSessionTitle: string;
|
||||||
|
runningCount: number;
|
||||||
|
unreadCount: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const hasActivity = runningCount > 0 || unreadCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-0 text-left transition-colors hover:bg-[var(--interactive-hover)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 flex-1 min-w-0 mr-2">
|
||||||
|
<span className="text-xs text-[var(--surface-foreground)] truncate flex-1 leading-tight">
|
||||||
|
{currentSessionTitle}
|
||||||
|
</span>
|
||||||
|
{hasActivity && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<RunningIndicator count={runningCount} />
|
||||||
|
<UnreadIndicator count={unreadCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedView({
|
||||||
|
runningCount,
|
||||||
|
unreadCount,
|
||||||
|
currentSessionTitle,
|
||||||
|
onToggle,
|
||||||
|
onNewSession
|
||||||
|
}: {
|
||||||
|
runningCount: number;
|
||||||
|
unreadCount: number;
|
||||||
|
currentSessionTitle: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNewSession: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex items-center justify-between px-2 border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first text-left">
|
||||||
|
<div className="flex-1 min-w-0 mr-2">
|
||||||
|
<SessionStatusHeader
|
||||||
|
currentSessionTitle={currentSessionTitle}
|
||||||
|
runningCount={runningCount}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNewSession();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-0.5 px-1.5 py-1 text-[11px] leading-tight !min-h-0 rounded border border-border/50 text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] flex-shrink-0 self-center"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(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 (
|
||||||
|
<div className="w-full border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first">
|
||||||
|
<div className="flex items-center justify-between px-2 py-0">
|
||||||
|
<div className="flex-1 min-w-0 mr-2">
|
||||||
|
<SessionStatusHeader
|
||||||
|
currentSessionTitle={currentSessionTitle}
|
||||||
|
runningCount={runningCount}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onToggle={onToggleCollapse}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNewSession();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-0.5 px-1.5 py-1 text-[11px] leading-tight !min-h-0 rounded border border-border/50 text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] self-start"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpand();
|
||||||
|
}}
|
||||||
|
className="text-[11px] leading-tight px-1.5 py-1 !min-h-0 rounded border border-border/50 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] self-start"
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Less' : 'More'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex flex-col overflow-y-auto"
|
||||||
|
style={{ maxHeight: isExpanded ? '60vh' : previewHeight }}
|
||||||
|
>
|
||||||
|
{displaySessions.map((session) => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
isCurrent={session.id === currentSessionId}
|
||||||
|
getSessionAgentName={getSessionAgentName}
|
||||||
|
getSessionTitle={getSessionTitle}
|
||||||
|
onClick={() => onSessionClick(session.id)}
|
||||||
|
needsAttention={needsAttention}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||||
|
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 (
|
||||||
|
<CollapsedView
|
||||||
|
runningCount={totalRunning}
|
||||||
|
unreadCount={totalUnread}
|
||||||
|
currentSessionTitle={currentSessionTitle}
|
||||||
|
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
||||||
|
onNewSession={handleCreateSession}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpandedView
|
||||||
|
sessions={sortedSessions}
|
||||||
|
currentSessionId={currentSessionId ?? ''}
|
||||||
|
runningCount={totalRunning}
|
||||||
|
unreadCount={totalUnread}
|
||||||
|
currentSessionTitle={currentSessionTitle}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggleCollapse={() => {
|
||||||
|
setIsMobileSessionStatusBarCollapsed(true);
|
||||||
|
setIsExpanded(false);
|
||||||
|
}}
|
||||||
|
onToggleExpand={() => setIsExpanded(!isExpanded)}
|
||||||
|
onNewSession={handleCreateSession}
|
||||||
|
onSessionClick={handleSessionClick}
|
||||||
|
getSessionAgentName={getSessionAgentName}
|
||||||
|
getSessionTitle={getSessionTitle}
|
||||||
|
needsAttention={needsAttention}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
||||||
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { getActiveSessionWindow } from '@/stores/types/sessionTypes';
|
import { getActiveSessionWindow } from '@/stores/types/sessionTypes';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
||||||
@@ -16,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
|
|||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||||
|
import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -392,8 +394,7 @@ export const useEventStream = () => {
|
|||||||
[bootstrapState]
|
[bootstrapState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
|
||||||
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
|
||||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||||
const previousSessionIdRef = React.useRef<string | null>(null);
|
const previousSessionIdRef = React.useRef<string | null>(null);
|
||||||
const previousSessionDirectoryRef = React.useRef<string | null>(null);
|
const previousSessionDirectoryRef = React.useRef<string | null>(null);
|
||||||
@@ -464,7 +465,6 @@ export const useEventStream = () => {
|
|||||||
[applySessionMetadata, getWorktreeMetadata]
|
[applySessionMetadata, getWorktreeMetadata]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
type SessionStatusPayload = {
|
type SessionStatusPayload = {
|
||||||
type: 'idle' | 'busy' | 'retry';
|
type: 'idle' | 'busy' | 'retry';
|
||||||
attempt?: number;
|
attempt?: number;
|
||||||
@@ -483,6 +483,9 @@ export const useEventStream = () => {
|
|||||||
const prevType = storeStatus?.type ?? 'idle';
|
const prevType = storeStatus?.type ?? 'idle';
|
||||||
const nextType = status?.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) {
|
if (prevType !== nextType) {
|
||||||
try {
|
try {
|
||||||
console.info('[SESSION-STATUS]', {
|
console.info('[SESSION-STATUS]', {
|
||||||
@@ -550,125 +553,18 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
const next = new Map(useSessionStore.getState().sessionStatus ?? new Map());
|
const next = new Map(useSessionStore.getState().sessionStatus ?? new Map());
|
||||||
if (nextType === 'idle') {
|
if (nextType === 'idle') {
|
||||||
next.delete(sessionId);
|
next.set(sessionId, { ...status, confirmedAt: Date.now() });
|
||||||
|
} else {
|
||||||
|
const existing = next.get(sessionId);
|
||||||
|
if (existing?.confirmedAt) {
|
||||||
|
next.set(sessionId, { ...status, confirmedAt: existing.confirmedAt });
|
||||||
} else {
|
} else {
|
||||||
next.set(sessionId, status);
|
next.set(sessionId, status);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
useSessionStore.setState({ sessionStatus: next });
|
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<string, { type?: string }>) => {
|
|
||||||
const observed = new Set<string>();
|
|
||||||
// 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<void> => {
|
|
||||||
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<string>();
|
|
||||||
// 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<string, { type?: string }> = {};
|
|
||||||
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(() => {
|
React.useEffect(() => {
|
||||||
const nextSessionId = currentSessionId ?? null;
|
const nextSessionId = currentSessionId ?? null;
|
||||||
const prevSessionId = previousSessionIdRef.current;
|
const prevSessionId = previousSessionIdRef.current;
|
||||||
@@ -677,13 +573,13 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previousSessionIdRef.current = nextSessionId;
|
previousSessionIdRef.current = nextSessionId;
|
||||||
previousSessionDirectoryRef.current = nextDirectory;
|
previousSessionDirectoryRef.current = nextDirectory;
|
||||||
}, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]);
|
}, [currentSessionId, resolveSessionDirectoryForStatus]);
|
||||||
|
|
||||||
const handleEvent = React.useCallback((event: EventData) => {
|
const handleEvent = React.useCallback((event: EventData) => {
|
||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
@@ -766,6 +662,47 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
break;
|
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<string, unknown> : {};
|
||||||
|
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': {
|
case 'message.part.updated': {
|
||||||
const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null;
|
const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null;
|
||||||
if (!part) break;
|
if (!part) break;
|
||||||
@@ -938,6 +875,18 @@ export const useEventStream = () => {
|
|||||||
trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role });
|
trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role });
|
||||||
|
|
||||||
if ((messageExt as { role?: unknown }).role === 'user') {
|
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 serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
|
||||||
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
|
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
|
||||||
const existingUserMessage = getMessageFromStore(sessionId, messageId);
|
const existingUserMessage = getMessageFromStore(sessionId, messageId);
|
||||||
@@ -1269,6 +1218,8 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
completeStreamingMessage(sessionId, messageId);
|
completeStreamingMessage(sessionId, messageId);
|
||||||
|
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:message.updated.completed');
|
||||||
|
// Removed: void refreshSessionStatus();
|
||||||
|
|
||||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||||
const messageSessionId: string =
|
const messageSessionId: string =
|
||||||
@@ -1345,6 +1296,9 @@ export const useEventStream = () => {
|
|||||||
? (props.messageID as string)
|
? (props.messageID as string)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.abort');
|
||||||
|
}
|
||||||
if (sessionId && messageId) {
|
if (sessionId && messageId) {
|
||||||
completeStreamingMessage(sessionId, messageId);
|
completeStreamingMessage(sessionId, messageId);
|
||||||
}
|
}
|
||||||
@@ -1533,11 +1487,12 @@ export const useEventStream = () => {
|
|||||||
applySessionMetadata,
|
applySessionMetadata,
|
||||||
trackMessage,
|
trackMessage,
|
||||||
reportMessage,
|
reportMessage,
|
||||||
updateSessionStatus,
|
|
||||||
updateSession,
|
updateSession,
|
||||||
removeSessionFromStore,
|
removeSessionFromStore,
|
||||||
bootstrapState,
|
bootstrapState,
|
||||||
effectiveDirectory
|
effectiveDirectory,
|
||||||
|
updateSessionStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const shouldHoldConnection = React.useCallback(() => {
|
const shouldHoldConnection = React.useCallback(() => {
|
||||||
@@ -1627,10 +1582,11 @@ export const useEventStream = () => {
|
|||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
publishStatus('connected', null);
|
publishStatus('connected', null);
|
||||||
checkConnection();
|
checkConnection();
|
||||||
|
triggerSessionStatusPoll();
|
||||||
|
|
||||||
// Always refresh session status on connect to detect any
|
// Always refresh session status on connect to detect any
|
||||||
// already-running sessions (e.g., started via CLI before UI opened)
|
// already-running sessions (e.g., started via CLI before UI opened)
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
void bootstrapState('sse_reconnected');
|
void bootstrapState('sse_reconnected');
|
||||||
@@ -1713,7 +1669,7 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
refreshSessionStatus,
|
|
||||||
debugConnectionState,
|
debugConnectionState,
|
||||||
bootstrapState
|
bootstrapState
|
||||||
]);
|
]);
|
||||||
@@ -1800,7 +1756,8 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
|
triggerSessionStatusPoll();
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
@@ -1825,7 +1782,8 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
|
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
|
||||||
}
|
}
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
|
triggerSessionStatusPoll();
|
||||||
|
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
@@ -1837,6 +1795,7 @@ export const useEventStream = () => {
|
|||||||
onlineStatusRef.current = true;
|
onlineStatusRef.current = true;
|
||||||
maybeBootstrapIfStale('network_restored');
|
maybeBootstrapIfStale('network_restored');
|
||||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||||
|
triggerSessionStatusPoll();
|
||||||
publishStatus('connecting', 'Network restored');
|
publishStatus('connecting', 'Network restored');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
@@ -1865,7 +1824,8 @@ export const useEventStream = () => {
|
|||||||
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
|
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
|
triggerSessionStatusPoll();
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1899,7 +1859,7 @@ export const useEventStream = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasBusySessions) {
|
if (hasBusySessions) {
|
||||||
void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
}
|
}
|
||||||
if (now - lastEventTimestampRef.current > 45000) {
|
if (now - lastEventTimestampRef.current > 45000) {
|
||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
@@ -1974,8 +1934,8 @@ export const useEventStream = () => {
|
|||||||
scheduleReconnect,
|
scheduleReconnect,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
updateSessionStatus,
|
|
||||||
refreshSessionStatus,
|
|
||||||
shouldHoldConnection,
|
shouldHoldConnection,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
maybeBootstrapIfStale,
|
maybeBootstrapIfStale,
|
||||||
|
|||||||
@@ -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<string, SessionState>;
|
||||||
|
attentionSessions: Record<string, SessionAttentionState>;
|
||||||
|
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<NodeJS.Timeout | null>(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<string, { type: 'idle' | 'busy' | 'retry'; confirmedAt?: number; attempt?: number; message?: string; next?: number }> | 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<string, SessionAttentionState> | 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;
|
||||||
@@ -8,6 +8,7 @@ import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus";
|
|||||||
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
|
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
|
||||||
import { useDirectoryStore } from "./useDirectoryStore";
|
import { useDirectoryStore } from "./useDirectoryStore";
|
||||||
import { useProjectsStore } from "./useProjectsStore";
|
import { useProjectsStore } from "./useProjectsStore";
|
||||||
|
import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus";
|
||||||
import type { ProjectEntry } from "@/lib/api/types";
|
import type { ProjectEntry } from "@/lib/api/types";
|
||||||
import { checkIsGitRepository } from "@/lib/gitApi";
|
import { checkIsGitRepository } from "@/lib/gitApi";
|
||||||
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||||
@@ -1237,7 +1238,26 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setCurrentSession: (id: string | null) => {
|
setCurrentSession: (id: string | null) => {
|
||||||
|
const prevSessionId = get().currentSessionId;
|
||||||
set({ currentSessionId: id, error: null });
|
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;
|
const directory = opencodeClient.getDirectory() ?? null;
|
||||||
storeSessionForDirectory(directory, id);
|
storeSessionForDirectory(directory, id);
|
||||||
},
|
},
|
||||||
@@ -1513,7 +1533,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
const mergedSessions = dedupeSessionsById(persistedSessions);
|
const mergedSessions = dedupeSessionsById(persistedSessions);
|
||||||
|
|
||||||
return {
|
const mergedResult = {
|
||||||
...currentState,
|
...currentState,
|
||||||
...persistedState,
|
...persistedState,
|
||||||
sessions: mergedSessions,
|
sessions: mergedSessions,
|
||||||
@@ -1527,6 +1547,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
: currentState.availableWorktreesByProject,
|
: currentState.availableWorktreesByProject,
|
||||||
lastLoadedDirectory,
|
lastLoadedDirectory,
|
||||||
};
|
};
|
||||||
|
return mergedResult;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface SessionMemoryState {
|
|||||||
hasMoreAbove?: boolean;
|
hasMoreAbove?: boolean;
|
||||||
trimmedHeadMaxId?: string;
|
trimmedHeadMaxId?: string;
|
||||||
streamingCooldownUntil?: number;
|
streamingCooldownUntil?: number;
|
||||||
|
lastUserMessageAt?: number; // Timestamp when user last sent a message
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionContextUsage {
|
export interface SessionContextUsage {
|
||||||
@@ -136,11 +137,22 @@ export interface SessionStore {
|
|||||||
|
|
||||||
// Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle).
|
// Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle).
|
||||||
// Use as the single source of truth for "assistant working" UI.
|
// 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<
|
sessionStatus?: Map<
|
||||||
string,
|
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<string, {
|
||||||
|
needsAttention: boolean;
|
||||||
|
lastUserMessageAt: number | null;
|
||||||
|
lastStatusChangeAt: number;
|
||||||
|
status: 'idle' | 'busy' | 'retry';
|
||||||
|
isViewed: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
|
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
|
||||||
|
|
||||||
pendingInputText: string | null;
|
pendingInputText: string | null;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
abortPromptSessionId: null,
|
abortPromptSessionId: null,
|
||||||
abortPromptExpiresAt: null,
|
abortPromptExpiresAt: null,
|
||||||
sessionStatus: new Map(),
|
sessionStatus: new Map(),
|
||||||
|
sessionAttentionStates: new Map(),
|
||||||
userSummaryTitles: new Map(),
|
userSummaryTitles: new Map(),
|
||||||
pendingInputText: null,
|
pendingInputText: null,
|
||||||
pendingInputMode: 'replace',
|
pendingInputMode: 'replace',
|
||||||
@@ -423,6 +424,26 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
setStatus(currentSessionId, 'busy');
|
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 {
|
try {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ interface UIStore {
|
|||||||
|
|
||||||
showTerminalQuickKeysOnDesktop: boolean;
|
showTerminalQuickKeysOnDesktop: boolean;
|
||||||
persistChatDraft: boolean;
|
persistChatDraft: boolean;
|
||||||
|
isMobileSessionStatusBarCollapsed: boolean;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -135,6 +136,7 @@ interface UIStore {
|
|||||||
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
||||||
setNotifyOnSubtasks: (value: boolean) => void;
|
setNotifyOnSubtasks: (value: boolean) => void;
|
||||||
setPersistChatDraft: (value: boolean) => void;
|
setPersistChatDraft: (value: boolean) => void;
|
||||||
|
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
@@ -199,6 +201,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
|
|
||||||
showTerminalQuickKeysOnDesktop: false,
|
showTerminalQuickKeysOnDesktop: false,
|
||||||
persistChatDraft: true,
|
persistChatDraft: true,
|
||||||
|
isMobileSessionStatusBarCollapsed: false,
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -688,6 +691,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setPersistChatDraft: (value) => {
|
setPersistChatDraft: (value) => {
|
||||||
set({ persistChatDraft: value });
|
set({ persistChatDraft: value });
|
||||||
},
|
},
|
||||||
|
setIsMobileSessionStatusBarCollapsed: (value) => {
|
||||||
|
set({ isMobileSessionStatusBarCollapsed: value });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
@@ -726,6 +732,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||||
persistChatDraft: state.persistChatDraft,
|
persistChatDraft: state.persistChatDraft,
|
||||||
|
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1608,6 +1608,246 @@ const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'|
|
|||||||
const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId
|
const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId
|
||||||
const SESSION_COOLDOWN_DURATION_MS = 2000;
|
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<clientId>,
|
||||||
|
// 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) => {
|
const setSessionActivityPhase = (sessionId, phase) => {
|
||||||
if (!sessionId || typeof sessionId !== 'string') return false;
|
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');
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
let separatorIndex;
|
let separatorIndex = buffer.indexOf('\n\n');
|
||||||
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
while (separatorIndex !== -1) {
|
||||||
const block = buffer.slice(0, separatorIndex);
|
const block = buffer.slice(0, separatorIndex);
|
||||||
buffer = buffer.slice(separatorIndex + 2);
|
buffer = buffer.slice(separatorIndex + 2);
|
||||||
|
separatorIndex = buffer.indexOf('\n\n');
|
||||||
const payload = parseSseDataPayload(block);
|
const payload = parseSseDataPayload(block);
|
||||||
void maybeSendPushForTrigger(payload);
|
void maybeSendPushForTrigger(payload);
|
||||||
// Track session activity independently of UI (mirrors Tauri desktop behavior)
|
// Track session activity independently of UI (mirrors Tauri desktop behavior)
|
||||||
@@ -2386,6 +2627,21 @@ const startGlobalEventWatcher = async () => {
|
|||||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
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) {
|
} catch (error) {
|
||||||
@@ -3899,6 +4155,114 @@ async function main(options = {}) {
|
|||||||
res.json(getSessionActivitySnapshot());
|
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) => {
|
app.get('/api/openchamber/update-check', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { checkForUpdates } = await import('./lib/package-manager.js');
|
const { checkForUpdates } = await import('./lib/package-manager.js');
|
||||||
@@ -4146,11 +4510,12 @@ async function main(options = {}) {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
let separatorIndex;
|
let separatorIndex = buffer.indexOf('\n\n');
|
||||||
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
while (separatorIndex !== -1) {
|
||||||
const block = buffer.slice(0, separatorIndex);
|
const block = buffer.slice(0, separatorIndex);
|
||||||
buffer = buffer.slice(separatorIndex + 2);
|
buffer = buffer.slice(separatorIndex + 2);
|
||||||
forwardBlock(block);
|
forwardBlock(block);
|
||||||
|
separatorIndex = buffer.indexOf('\n\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4270,11 +4635,12 @@ async function main(options = {}) {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
let separatorIndex;
|
let separatorIndex = buffer.indexOf('\n\n');
|
||||||
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
while (separatorIndex !== -1) {
|
||||||
const block = buffer.slice(0, separatorIndex);
|
const block = buffer.slice(0, separatorIndex);
|
||||||
buffer = buffer.slice(separatorIndex + 2);
|
buffer = buffer.slice(separatorIndex + 2);
|
||||||
forwardBlock(block);
|
forwardBlock(block);
|
||||||
|
separatorIndex = buffer.indexOf('\n\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user