fix desktop notifications and chat scroll stability
This commit is contained in:
@@ -175,6 +175,7 @@ function App({ apis }: AppProps) {
|
||||
const appReadyDispatchedRef = React.useRef(false);
|
||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||
const recentDesktopNotificationTagsRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
setStreamPerfEnabled(showMemoryDebug);
|
||||
@@ -371,6 +372,63 @@ function App({ apis }: AppProps) {
|
||||
};
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat || !isDesktopRuntime || typeof window === 'undefined' || typeof EventSource === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource('/api/notifications/stream');
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
type DesktopNotificationEvent = {
|
||||
type?: string;
|
||||
properties?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
};
|
||||
};
|
||||
|
||||
let payload: DesktopNotificationEvent;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(event.data) as DesktopNotificationEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload?.type !== 'openchamber:notification') {
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = typeof payload.properties?.tag === 'string' ? payload.properties.tag : '';
|
||||
if (tag) {
|
||||
const now = Date.now();
|
||||
const lastSeenAt = recentDesktopNotificationTagsRef.current.get(tag) ?? 0;
|
||||
if (now - lastSeenAt < 5000) {
|
||||
return;
|
||||
}
|
||||
recentDesktopNotificationTagsRef.current.set(tag, now);
|
||||
}
|
||||
|
||||
void apis.notifications.notifyAgentCompletion({
|
||||
title: payload.properties?.title,
|
||||
body: payload.properties?.body,
|
||||
tag: tag || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
source.addEventListener('message', handleMessage as EventListener);
|
||||
source.onerror = () => {
|
||||
// Let EventSource reconnect automatically.
|
||||
};
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('message', handleMessage as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [apis.notifications, embeddedSessionChat, isDesktopRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
|
||||
return;
|
||||
|
||||
@@ -631,6 +631,7 @@ export const ChatContainer: React.FC = () => {
|
||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||
|
||||
const hasHistoryMetadata = Boolean(historyMeta);
|
||||
const lastHydratedSessionRef = React.useRef<string | null>(null);
|
||||
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
@@ -640,12 +641,15 @@ export const ChatContainer: React.FC = () => {
|
||||
if (!currentSessionId) return;
|
||||
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
||||
|
||||
const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId;
|
||||
lastHydratedSessionRef.current = currentSessionId;
|
||||
|
||||
const load = async () => {
|
||||
await loadMessages(currentSessionId).finally(() => {
|
||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget;
|
||||
const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch);
|
||||
|
||||
if (!shouldSkipScroll) {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -1692,17 +1692,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto';
|
||||
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior });
|
||||
const targetTop = Math.max(0, container.scrollTop - 50);
|
||||
container.scrollTo({ top: targetTop, behavior });
|
||||
return true;
|
||||
}, [historyEntries.length, historyVirtualizer, resolveScrollContainer, shouldVirtualizeHistory]);
|
||||
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
|
||||
|
||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||
const container = resolveScrollContainer();
|
||||
|
||||
@@ -14,19 +14,17 @@ import {
|
||||
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
||||
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
||||
|
||||
const waitForFrames = async (count = 1): Promise<void> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type ViewportAnchor = { messageId: string; offsetTop: number };
|
||||
|
||||
type PendingScrollRequest = {
|
||||
sessionId: string;
|
||||
kind: 'turn' | 'message';
|
||||
id: string;
|
||||
behavior: ScrollBehavior;
|
||||
turnId: string | null;
|
||||
resolve: (value: boolean) => void;
|
||||
};
|
||||
|
||||
interface UseChatTimelineControllerOptions {
|
||||
sessionId: string | null;
|
||||
messages: ChatMessageEntry[];
|
||||
@@ -100,6 +98,8 @@ export const useChatTimelineController = ({
|
||||
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
|
||||
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
|
||||
const initializedSessionRef = React.useRef<string | null>(null);
|
||||
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
||||
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
||||
|
||||
const historySignals = React.useMemo(() => {
|
||||
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
||||
@@ -189,10 +189,78 @@ export const useChatTimelineController = ({
|
||||
previousTurnCountRef.current = nextTurnCount;
|
||||
}, [turnWindowModel.turnCount]);
|
||||
|
||||
const resolvePendingRenderWaiters = React.useCallback(() => {
|
||||
const resolvers = pendingRenderResolversRef.current;
|
||||
if (resolvers.length === 0) {
|
||||
return;
|
||||
}
|
||||
pendingRenderResolversRef.current = [];
|
||||
resolvers.forEach((resolve) => resolve());
|
||||
}, []);
|
||||
|
||||
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
pendingRenderResolversRef.current.push(resolve);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
|
||||
const pending = pendingScrollRequestRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pendingScrollRequestRef.current = null;
|
||||
pending.resolve(value);
|
||||
}, []);
|
||||
|
||||
const attemptPendingScrollRequest = React.useCallback(() => {
|
||||
const pending = pendingScrollRequestRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.sessionId !== sessionIdRef.current) {
|
||||
resolvePendingScrollRequest(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const didScroll = pending.kind === 'turn'
|
||||
? (messageListRef.current?.scrollToTurnId(pending.id, { behavior: pending.behavior }) ?? false)
|
||||
: (messageListRef.current?.scrollToMessageId(pending.id, { behavior: pending.behavior }) ?? false);
|
||||
|
||||
if (didScroll) {
|
||||
if (pending.turnId) {
|
||||
setActiveTurnId(pending.turnId);
|
||||
}
|
||||
resolvePendingScrollRequest(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = pending.kind === 'turn'
|
||||
? turnModelRef.current.turnIndexById.get(pending.id)
|
||||
: turnModelRef.current.messageToTurnIndex.get(pending.id);
|
||||
|
||||
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
|
||||
resolvePendingScrollRequest(false);
|
||||
}
|
||||
}, [messageListRef, resolvePendingScrollRequest]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
resolvePendingRenderWaiters();
|
||||
resolvePendingScrollRequest(false);
|
||||
};
|
||||
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
|
||||
|
||||
const renderedMessages = React.useMemo(() => {
|
||||
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
||||
}, [messages, turnStart, turnWindowModel]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
resolvePendingRenderWaiters();
|
||||
attemptPendingScrollRequest();
|
||||
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
|
||||
|
||||
// --- Synchronous scroll compensation for load-more / reveal ---
|
||||
// fetchOlderHistory and revealBufferedTurns store a snapshot here
|
||||
// before triggering the state change. useLayoutEffect consumes it
|
||||
@@ -257,10 +325,10 @@ export const useChatTimelineController = ({
|
||||
return next > 0 ? next : 0;
|
||||
});
|
||||
|
||||
await waitForFrames(1);
|
||||
await waitForNextRenderCommit();
|
||||
setPendingRevealWork(false);
|
||||
return true;
|
||||
}, [captureViewportAnchor, scrollRef]);
|
||||
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
|
||||
|
||||
const fetchOlderHistory = React.useCallback(async (input: {
|
||||
preserveViewport: boolean;
|
||||
@@ -344,26 +412,29 @@ export const useChatTimelineController = ({
|
||||
|
||||
if (turnIndex < turnStartRef.current) {
|
||||
setTurnStart(turnIndex);
|
||||
await waitForFrames(2);
|
||||
}
|
||||
|
||||
const didScroll = messageListRef.current?.scrollToTurnId(turnId, {
|
||||
behavior: options?.behavior,
|
||||
}) ?? false;
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
pendingScrollRequestRef.current = {
|
||||
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||
kind: 'turn',
|
||||
id: turnId,
|
||||
behavior: options?.behavior ?? 'auto',
|
||||
turnId,
|
||||
resolve,
|
||||
};
|
||||
attemptPendingScrollRequest();
|
||||
});
|
||||
|
||||
if (didScroll) {
|
||||
setActiveTurnId(turnId);
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await waitForFrames(2);
|
||||
return messageListRef.current?.scrollToTurnId(turnId, {
|
||||
behavior: options?.behavior,
|
||||
}) ?? false;
|
||||
return false;
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}, [messageListRef, sessionId]);
|
||||
}, [attemptPendingScrollRequest, sessionId]);
|
||||
|
||||
const scrollToMessage = React.useCallback(async (
|
||||
messageId: string,
|
||||
@@ -389,28 +460,29 @@ export const useChatTimelineController = ({
|
||||
|
||||
if (turnIndex < turnStartRef.current) {
|
||||
setTurnStart(turnIndex);
|
||||
await waitForFrames(2);
|
||||
}
|
||||
|
||||
const didScroll = messageListRef.current?.scrollToMessageId(messageId, {
|
||||
behavior: options?.behavior,
|
||||
}) ?? false;
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
pendingScrollRequestRef.current = {
|
||||
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||
kind: 'message',
|
||||
id: messageId,
|
||||
behavior: options?.behavior ?? 'auto',
|
||||
turnId: turnId ?? null,
|
||||
resolve,
|
||||
};
|
||||
attemptPendingScrollRequest();
|
||||
});
|
||||
|
||||
if (didScroll) {
|
||||
if (turnId) {
|
||||
setActiveTurnId(turnId);
|
||||
}
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await waitForFrames(2);
|
||||
return messageListRef.current?.scrollToMessageId(messageId, {
|
||||
behavior: options?.behavior,
|
||||
}) ?? false;
|
||||
return false;
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}, [messageListRef, sessionId]);
|
||||
}, [attemptPendingScrollRequest, sessionId]);
|
||||
|
||||
const resumeToBottom = React.useCallback(() => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
|
||||
@@ -114,6 +114,7 @@ export const useChatScrollManager = ({
|
||||
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
||||
const preferInstantPinRef = React.useRef(false);
|
||||
const autoFollowDuringWorkRef = React.useRef(false);
|
||||
const pendingSessionSwitchSnapRef = React.useRef(false);
|
||||
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
@@ -206,21 +207,35 @@ export const useChatScrollManager = ({
|
||||
pinnedSyncRafRef.current = null;
|
||||
updateScrollButtonVisibility();
|
||||
if (!isPinnedRef.current) {
|
||||
pendingSessionSwitchSnapRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (sessionIsWorking) {
|
||||
if (distanceFromBottom <= 0.5) {
|
||||
if (pendingSessionSwitchSnapRef.current && distanceFromBottom > 0.5) {
|
||||
autoFollowDuringWorkRef.current = false;
|
||||
scrollToBottomInternal({ instant: true });
|
||||
pendingSessionSwitchSnapRef.current = false;
|
||||
preferInstantPinRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (distanceFromBottom <= 0.5) {
|
||||
if (!pendingSessionSwitchSnapRef.current) {
|
||||
preferInstantPinRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
scrollPinnedToBottom(distanceFromBottom);
|
||||
preferInstantPinRef.current = false;
|
||||
pendingSessionSwitchSnapRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSessionSwitchSnapRef.current = false;
|
||||
|
||||
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
||||
preferInstantPinRef.current = false;
|
||||
return;
|
||||
@@ -480,6 +495,7 @@ export const useChatScrollManager = ({
|
||||
flushViewportAnchor();
|
||||
pendingViewportAnchorRef.current = null;
|
||||
autoFollowDuringWorkRef.current = false;
|
||||
pendingSessionSwitchSnapRef.current = true;
|
||||
|
||||
// Always start pinned at bottom on session switch
|
||||
preferInstantPinRef.current = true;
|
||||
@@ -497,6 +513,7 @@ export const useChatScrollManager = ({
|
||||
React.useEffect(() => {
|
||||
if (!sessionIsWorking) {
|
||||
autoFollowDuringWorkRef.current = false;
|
||||
pendingSessionSwitchSnapRef.current = false;
|
||||
}
|
||||
}, [sessionIsWorking]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user