Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -72,7 +72,7 @@ const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
||||
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
||||
const PIN_THRESHOLD_RATIO = 0.10;
|
||||
const SORTED_PIN_THRESHOLD_PX = 24;
|
||||
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
|
||||
|
||||
export const useChatScrollManager = ({
|
||||
currentSessionId,
|
||||
@@ -81,7 +81,6 @@ export const useChatScrollManager = ({
|
||||
updateViewportAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
chatRenderMode = 'live',
|
||||
onActiveTurnChange,
|
||||
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -97,11 +96,8 @@ export const useChatScrollManager = ({
|
||||
}, []);
|
||||
|
||||
const getAutoFollowThreshold = React.useCallback(() => {
|
||||
if (chatRenderMode === 'sorted') {
|
||||
return SORTED_PIN_THRESHOLD_PX;
|
||||
}
|
||||
return getPinThreshold();
|
||||
}, [chatRenderMode, getPinThreshold]);
|
||||
}, [getPinThreshold]);
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
@@ -113,6 +109,11 @@ export const useChatScrollManager = ({
|
||||
const isPinnedRef = React.useRef(true);
|
||||
const lastScrollTopRef = React.useRef<number>(0);
|
||||
const touchLastYRef = React.useRef<number | null>(null);
|
||||
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
||||
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);
|
||||
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
|
||||
|
||||
const markProgrammaticScroll = React.useCallback(() => {
|
||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||
@@ -169,6 +170,79 @@ export const useChatScrollManager = ({
|
||||
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||
}, [getDistanceFromBottom, getPinThreshold]);
|
||||
|
||||
const syncPinnedStateAndIndicators = React.useCallback(() => {
|
||||
pinnedSyncRafRef.current = null;
|
||||
updateScrollButtonVisibility();
|
||||
if (!isPinnedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
syncPinnedStateAndIndicators();
|
||||
return;
|
||||
}
|
||||
if (pinnedSyncRafRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
pinnedSyncRafRef.current = window.requestAnimationFrame(() => {
|
||||
syncPinnedStateAndIndicators();
|
||||
});
|
||||
}, [syncPinnedStateAndIndicators]);
|
||||
|
||||
const flushViewportAnchor = React.useCallback(() => {
|
||||
if (viewportAnchorTimerRef.current !== null) {
|
||||
clearTimeout(viewportAnchorTimerRef.current);
|
||||
viewportAnchorTimerRef.current = null;
|
||||
}
|
||||
|
||||
const pending = pendingViewportAnchorRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPersisted = lastViewportAnchorRef.current;
|
||||
if (lastPersisted && lastPersisted.sessionId === pending.sessionId && lastPersisted.anchor === pending.anchor) {
|
||||
pendingViewportAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
updateViewportAnchor(pending.sessionId, pending.anchor);
|
||||
lastViewportAnchorRef.current = pending;
|
||||
pendingViewportAnchorRef.current = null;
|
||||
lastViewportAnchorWriteAtRef.current = Date.now();
|
||||
}, [updateViewportAnchor]);
|
||||
|
||||
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number) => {
|
||||
const lastPersisted = lastViewportAnchorRef.current;
|
||||
if (lastPersisted && lastPersisted.sessionId === sessionId && lastPersisted.anchor === anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingViewportAnchorRef.current = { sessionId, anchor };
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastViewportAnchorWriteAtRef.current;
|
||||
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
|
||||
flushViewportAnchor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportAnchorTimerRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewportAnchorTimerRef.current = setTimeout(() => {
|
||||
viewportAnchorTimerRef.current = null;
|
||||
flushViewportAnchor();
|
||||
}, VIEWPORT_ANCHOR_MIN_UPDATE_MS - elapsed);
|
||||
}, [flushViewportAnchor]);
|
||||
|
||||
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
@@ -191,8 +265,8 @@ export const useChatScrollManager = ({
|
||||
const releasePinnedScroll = React.useCallback(() => {
|
||||
scrollEngine.cancelFollow();
|
||||
updatePinnedState(false);
|
||||
updateScrollButtonVisibility();
|
||||
}, [scrollEngine, updatePinnedState, updateScrollButtonVisibility]);
|
||||
schedulePinnedStateAndIndicators();
|
||||
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
|
||||
|
||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||
const container = scrollRef.current;
|
||||
@@ -205,7 +279,7 @@ export const useChatScrollManager = ({
|
||||
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
|
||||
|
||||
scrollEngine.handleScroll();
|
||||
updateScrollButtonVisibility();
|
||||
schedulePinnedStateAndIndicators();
|
||||
|
||||
// Handle pin/unpin logic
|
||||
const currentScrollTop = container.scrollTop;
|
||||
@@ -221,7 +295,7 @@ export const useChatScrollManager = ({
|
||||
// Re-pin at bottom should always work (even momentum scroll)
|
||||
if (!isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (!scrollingUp && distanceFromBottom <= getPinThreshold()) {
|
||||
if (distanceFromBottom <= getPinThreshold()) {
|
||||
updatePinnedState(true);
|
||||
}
|
||||
}
|
||||
@@ -231,16 +305,16 @@ export const useChatScrollManager = ({
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
|
||||
const estimatedIndex = Math.floor(position * sessionMessages.length);
|
||||
updateViewportAnchor(currentSessionId, estimatedIndex);
|
||||
queueViewportAnchor(currentSessionId, estimatedIndex);
|
||||
}, [
|
||||
currentSessionId,
|
||||
getDistanceFromBottom,
|
||||
getPinThreshold,
|
||||
queueViewportAnchor,
|
||||
schedulePinnedStateAndIndicators,
|
||||
scrollEngine,
|
||||
sessionMessages.length,
|
||||
updatePinnedState,
|
||||
updateScrollButtonVisibility,
|
||||
updateViewportAnchor,
|
||||
]);
|
||||
|
||||
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
|
||||
@@ -345,6 +419,8 @@ export const useChatScrollManager = ({
|
||||
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
flushViewportAnchor();
|
||||
pendingViewportAnchorRef.current = null;
|
||||
|
||||
// Always start pinned at bottom on session switch
|
||||
updatePinnedState(true);
|
||||
@@ -355,22 +431,15 @@ export const useChatScrollManager = ({
|
||||
markProgrammaticScroll();
|
||||
scrollToBottomInternal({ instant: true });
|
||||
}
|
||||
}, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
|
||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
|
||||
|
||||
// Maintain pin-to-bottom when content changes
|
||||
React.useEffect(() => {
|
||||
if (!isPinnedRef.current) return;
|
||||
if (isSyncing) return;
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// When pinned and content grows, follow bottom with fast smooth scroll
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, isSyncing, scrollPinnedToBottom, sessionMessages]);
|
||||
schedulePinnedStateAndIndicators();
|
||||
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
||||
|
||||
// Use ResizeObserver to detect content changes and maintain pin
|
||||
React.useEffect(() => {
|
||||
@@ -378,27 +447,14 @@ export const useChatScrollManager = ({
|
||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content grows - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
// Also observe children for content changes
|
||||
const childObserver = new MutationObserver(() => {
|
||||
if (isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
childObserver.observe(container, { childList: true, subtree: true });
|
||||
@@ -407,36 +463,28 @@ export const useChatScrollManager = ({
|
||||
observer.disconnect();
|
||||
childObserver.disconnect();
|
||||
};
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
updateScrollButtonVisibility();
|
||||
schedulePinnedStateAndIndicators();
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
updateScrollButtonVisibility();
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
|
||||
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
||||
|
||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
const handleMessageContentChange = React.useCallback(() => {
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content changes - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
schedulePinnedStateAndIndicators();
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const existing = animationHandlersRef.current.get(messageId);
|
||||
@@ -446,27 +494,15 @@ export const useChatScrollManager = ({
|
||||
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
},
|
||||
onComplete: () => {
|
||||
updateScrollButtonVisibility();
|
||||
schedulePinnedStateAndIndicators();
|
||||
},
|
||||
onStreamingCandidate: () => {},
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
},
|
||||
onReservationCancelled: () => {},
|
||||
onReasoningBlock: () => {},
|
||||
@@ -474,7 +510,22 @@ export const useChatScrollManager = ({
|
||||
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pinnedSyncRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(pinnedSyncRafRef.current);
|
||||
pinnedSyncRafRef.current = null;
|
||||
}
|
||||
|
||||
flushViewportAnchor();
|
||||
if (viewportAnchorTimerRef.current !== null) {
|
||||
clearTimeout(viewportAnchorTimerRef.current);
|
||||
viewportAnchorTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [flushViewportAnchor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) {
|
||||
@@ -495,23 +546,74 @@ export const useChatScrollManager = ({
|
||||
|
||||
spy.setContainer(container);
|
||||
|
||||
const registerTurns = () => {
|
||||
spy.clear();
|
||||
const turnNodes = container.querySelectorAll<HTMLElement>('[data-turn-id]');
|
||||
turnNodes.forEach((node) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
spy.register(node, turnId);
|
||||
});
|
||||
spy.markDirty();
|
||||
const elementByTurnId = new Map<string, HTMLElement>();
|
||||
|
||||
const registerTurnNode = (node: HTMLElement): boolean => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) {
|
||||
return false;
|
||||
}
|
||||
elementByTurnId.set(turnId, node);
|
||||
spy.register(node, turnId);
|
||||
return true;
|
||||
};
|
||||
|
||||
registerTurns();
|
||||
const unregisterTurnNode = (node: HTMLElement): boolean => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) {
|
||||
return false;
|
||||
}
|
||||
if (elementByTurnId.get(turnId) !== node) {
|
||||
return false;
|
||||
}
|
||||
elementByTurnId.delete(turnId);
|
||||
spy.unregister(turnId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
registerTurns();
|
||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
const collected: HTMLElement[] = [];
|
||||
if (node.matches('[data-turn-id]')) {
|
||||
collected.push(node);
|
||||
}
|
||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((turnNode) => {
|
||||
collected.push(turnNode);
|
||||
});
|
||||
return collected;
|
||||
};
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((node) => {
|
||||
registerTurnNode(node);
|
||||
});
|
||||
spy.markDirty();
|
||||
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
let changed = false;
|
||||
|
||||
records.forEach((record) => {
|
||||
record.removedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (unregisterTurnNode(turnNode)) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
record.addedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (registerTurnNode(turnNode)) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
spy.markDirty();
|
||||
}
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
|
||||
@@ -176,9 +176,28 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const RESYNC_DEBOUNCE_MS = 750;
|
||||
const QUESTION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
const PERMISSION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
const RESYNC_DEBOUNCE_MS = 1800;
|
||||
const QUESTION_RECONCILE_COOLDOWN_MS = 3000;
|
||||
const PERMISSION_RECONCILE_COOLDOWN_MS = 3000;
|
||||
const DERIVED_STATE_REFRESH_COOLDOWN_MS = 2500;
|
||||
const GIT_REFRESH_HINT_DEDUP_WINDOW_MS = 5000;
|
||||
const GIT_REFRESH_HINT_TOOL_NAMES = new Set([
|
||||
'edit',
|
||||
'multiedit',
|
||||
'apply_patch',
|
||||
'write',
|
||||
'file_write',
|
||||
'create',
|
||||
'bash',
|
||||
]);
|
||||
const GIT_REFRESH_HINT_COMPLETED_STATES = new Set([
|
||||
'completed',
|
||||
'complete',
|
||||
'failed',
|
||||
'error',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
]);
|
||||
|
||||
const readEventDirectory = (props: Record<string, unknown>): string => {
|
||||
const directory = readStringProp(props, ['directory']);
|
||||
@@ -534,6 +553,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
||||
const sessionActivityLastRefreshAtRef = React.useRef<number>(0);
|
||||
const sessionActivityRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||
const lastDerivedActivityRepairAtRef = React.useRef<number>(0);
|
||||
const lastDerivedStatusRepairAtRef = React.useRef<number>(0);
|
||||
const lastGitRefreshHintAtRef = React.useRef<Map<string, number>>(new Map());
|
||||
const scheduleSoftResyncRef = React.useRef<
|
||||
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
@@ -605,6 +627,44 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
[bootstrapState]
|
||||
);
|
||||
|
||||
const emitGitRefreshHint = React.useCallback((params: {
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
partId?: string | null;
|
||||
toolName: string;
|
||||
toolState: string;
|
||||
}) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupKey = `${params.sessionId}:${params.messageId}:${params.partId ?? 'unknown'}:${params.toolName}:${params.toolState}`;
|
||||
const now = Date.now();
|
||||
const lastAt = lastGitRefreshHintAtRef.current.get(dedupKey) ?? 0;
|
||||
if (now - lastAt < GIT_REFRESH_HINT_DEDUP_WINDOW_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastGitRefreshHintAtRef.current.set(dedupKey, now);
|
||||
if (lastGitRefreshHintAtRef.current.size > 600) {
|
||||
const firstKey = lastGitRefreshHintAtRef.current.keys().next().value;
|
||||
if (typeof firstKey === 'string') {
|
||||
lastGitRefreshHintAtRef.current.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('openchamber:git-refresh-hint', {
|
||||
detail: {
|
||||
directory: params.directory,
|
||||
sessionId: params.sessionId,
|
||||
messageId: params.messageId,
|
||||
toolName: params.toolName,
|
||||
toolState: params.toolState,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
|
||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||
const previousSessionIdRef = React.useRef<string | null>(null);
|
||||
@@ -895,21 +955,29 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
const repairSessionDerivedState = React.useCallback((
|
||||
reason: string,
|
||||
options?: { refreshActivity?: boolean; pollStatus?: boolean }
|
||||
options?: { refreshActivity?: boolean; pollStatus?: boolean; immediate?: boolean }
|
||||
) => {
|
||||
const refreshActivity = options?.refreshActivity !== false;
|
||||
const pollStatus = options?.pollStatus !== false;
|
||||
const immediate = options?.immediate === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus });
|
||||
console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus, immediate });
|
||||
}
|
||||
|
||||
if (refreshActivity) {
|
||||
void refreshSessionActivityStatus();
|
||||
if (immediate || now - lastDerivedActivityRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) {
|
||||
lastDerivedActivityRepairAtRef.current = now;
|
||||
void refreshSessionActivityStatus();
|
||||
}
|
||||
}
|
||||
|
||||
if (pollStatus) {
|
||||
triggerSessionStatusPoll();
|
||||
if (immediate || now - lastDerivedStatusRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) {
|
||||
lastDerivedStatusRepairAtRef.current = now;
|
||||
triggerSessionStatusPoll();
|
||||
}
|
||||
}
|
||||
}, [refreshSessionActivityStatus]);
|
||||
|
||||
@@ -1235,18 +1303,36 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const partTime = (messagePart as { time?: { end?: unknown } }).time;
|
||||
const partHasEnded = typeof partTime?.end === 'number';
|
||||
const toolState = (messagePart as { state?: { status?: unknown } }).state?.status;
|
||||
const normalizedToolState = typeof toolState === 'string' ? toolState.toLowerCase() : null;
|
||||
const toolName = typeof (messagePart as { tool?: unknown }).tool === 'string'
|
||||
? (messagePart as { tool: string }).tool.toLowerCase()
|
||||
: null;
|
||||
const textContent = (messagePart as { text?: unknown }).text;
|
||||
|
||||
if (
|
||||
partType === 'tool'
|
||||
&& toolName
|
||||
&& GIT_REFRESH_HINT_TOOL_NAMES.has(toolName)
|
||||
&& normalizedToolState
|
||||
&& GIT_REFRESH_HINT_COMPLETED_STATES.has(normalizedToolState)
|
||||
) {
|
||||
emitGitRefreshHint({
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
partId: updatedPartId,
|
||||
toolName,
|
||||
toolState: normalizedToolState,
|
||||
});
|
||||
}
|
||||
|
||||
if (partType === 'tool' && toolName === 'question') {
|
||||
requestPendingQuestionsRefresh();
|
||||
}
|
||||
|
||||
const isStreamingPart = (() => {
|
||||
if (partType === 'tool') {
|
||||
return toolState === 'running' || toolState === 'pending';
|
||||
return normalizedToolState === 'running' || normalizedToolState === 'pending';
|
||||
}
|
||||
if (partType === 'reasoning') {
|
||||
return !partHasEnded;
|
||||
@@ -2082,6 +2168,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
updateSessionActivityPhase,
|
||||
repairSessionDerivedState,
|
||||
dispatchRuntimeNotification,
|
||||
emitGitRefreshHint,
|
||||
writePartTypeHint,
|
||||
]);
|
||||
|
||||
|
||||
@@ -9,10 +9,21 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
* Must be used inside RuntimeAPIProvider.
|
||||
*/
|
||||
export function useGitPolling() {
|
||||
const FORCE_DIFF_REFRESH_TOOLS = React.useMemo(() => new Set([
|
||||
'edit',
|
||||
'multiedit',
|
||||
'apply_patch',
|
||||
'write',
|
||||
'file_write',
|
||||
'create',
|
||||
]), []);
|
||||
|
||||
const { git } = useRuntimeAPIs();
|
||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||
const { setActiveDirectory, startPolling, stopPolling, fetchAll } = useGitStore();
|
||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap, sessionStatus } = useSessionStore();
|
||||
const { setActiveDirectory, startPolling, setPollingMode, stopPolling, fetchAll, fetchStatus, clearDiffCache } = useGitStore();
|
||||
const immediateRefreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastImmediateRefreshAtRef = React.useRef<number>(0);
|
||||
|
||||
const effectiveDirectory = React.useMemo(() => {
|
||||
const worktreeMetadata = currentSessionId
|
||||
@@ -25,6 +36,62 @@ export function useGitPolling() {
|
||||
return worktreeMetadata?.path ?? sessionDirectory ?? fallbackDirectory ?? null;
|
||||
}, [currentSessionId, sessions, worktreeMap, fallbackDirectory]);
|
||||
|
||||
const activeSessionStatus = React.useMemo<'idle' | 'busy' | 'retry'>(() => {
|
||||
if (!currentSessionId) {
|
||||
return 'idle';
|
||||
}
|
||||
const activeStatus = sessionStatus?.get(currentSessionId)?.type;
|
||||
if (activeStatus === 'busy' || activeStatus === 'retry') {
|
||||
return activeStatus;
|
||||
}
|
||||
return 'idle';
|
||||
}, [currentSessionId, sessionStatus]);
|
||||
|
||||
const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal';
|
||||
|
||||
React.useEffect(() => {
|
||||
setPollingMode(pollingMode);
|
||||
}, [pollingMode, setPollingMode]);
|
||||
|
||||
const queueImmediateStatusRefresh = React.useCallback((
|
||||
delayMs: number = 300,
|
||||
options?: { directory?: string | null; forceDiffRefresh?: boolean }
|
||||
) => {
|
||||
if (!git) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hintedDirectory = typeof options?.directory === 'string' && options.directory.trim().length > 0 && options.directory !== 'global'
|
||||
? options.directory.trim()
|
||||
: null;
|
||||
const targetDirectory = hintedDirectory ?? effectiveDirectory;
|
||||
if (!targetDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldForceDiffRefresh = options?.forceDiffRefresh === true;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastImmediateRefreshAtRef.current < 800) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediateRefreshTimerRef.current) {
|
||||
clearTimeout(immediateRefreshTimerRef.current);
|
||||
}
|
||||
|
||||
immediateRefreshTimerRef.current = setTimeout(() => {
|
||||
immediateRefreshTimerRef.current = null;
|
||||
lastImmediateRefreshAtRef.current = Date.now();
|
||||
void (async () => {
|
||||
const statusChanged = await fetchStatus(targetDirectory, git, { silent: true });
|
||||
if (shouldForceDiffRefresh && !statusChanged) {
|
||||
clearDiffCache(targetDirectory);
|
||||
}
|
||||
})();
|
||||
}, delayMs);
|
||||
}, [clearDiffCache, effectiveDirectory, fetchStatus, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!effectiveDirectory || !git) {
|
||||
stopPolling();
|
||||
@@ -34,11 +101,41 @@ export function useGitPolling() {
|
||||
setActiveDirectory(effectiveDirectory);
|
||||
|
||||
void fetchAll(effectiveDirectory, git, { silentIfCached: true });
|
||||
|
||||
startPolling(git);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [effectiveDirectory, git, setActiveDirectory, startPolling, stopPolling, fetchAll]);
|
||||
}, [activeSessionStatus, effectiveDirectory, fetchAll, git, setActiveDirectory, startPolling, stopPolling]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleGitRefreshHint = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ directory?: string | null; toolName?: string | null }>;
|
||||
const toolName = typeof customEvent.detail?.toolName === 'string'
|
||||
? customEvent.detail.toolName.toLowerCase()
|
||||
: null;
|
||||
queueImmediateStatusRefresh(200, {
|
||||
directory: customEvent.detail?.directory ?? null,
|
||||
forceDiffRefresh: Boolean(toolName && FORCE_DIFF_REFRESH_TOOLS.has(toolName)),
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('openchamber:git-refresh-hint', handleGitRefreshHint as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('openchamber:git-refresh-hint', handleGitRefreshHint as EventListener);
|
||||
};
|
||||
}, [FORCE_DIFF_REFRESH_TOOLS, queueImmediateStatusRefresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (immediateRefreshTimerRef.current) {
|
||||
clearTimeout(immediateRefreshTimerRef.current);
|
||||
immediateRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
toggleNavRail,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab,
|
||||
@@ -134,12 +133,6 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('toggle_nav_rail'))) {
|
||||
e.preventDefault();
|
||||
toggleNavRail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('focus_input'))) {
|
||||
e.preventDefault();
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
@@ -427,7 +420,6 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
toggleNavRail,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab,
|
||||
|
||||
@@ -42,7 +42,7 @@ const buildLogoCandidates = (providerId: string | null | undefined) => {
|
||||
|
||||
const compact = normalized.replace(/[^a-z0-9_\-./:]/g, '');
|
||||
const primary = compact.split(/[/:]/)[0] || compact;
|
||||
const candidates = [compact, primary, LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary)]
|
||||
const candidates = [LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary]
|
||||
.filter((value): value is string => Boolean(value && value.length > 0));
|
||||
|
||||
return [...new Set(candidates)];
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
const HEARTBEAT_MS = 10000;
|
||||
const HEARTBEAT_MS = 20000;
|
||||
|
||||
const resolveVisibilityState = (): 'visible' | 'hidden' => {
|
||||
if (typeof document === 'undefined') return 'visible';
|
||||
|
||||
@@ -38,6 +38,7 @@ const LERP_FACTOR = 0.14;
|
||||
|
||||
// When the remaining distance is below this, snap exactly to bottom.
|
||||
const SNAP_EPSILON = 0.5;
|
||||
const FOLLOW_STABLE_FRAME_LIMIT = 8;
|
||||
|
||||
export const useScrollEngine = ({
|
||||
containerRef,
|
||||
@@ -81,6 +82,7 @@ export const useScrollEngine = ({
|
||||
if (followActiveRef.current) return; // already running
|
||||
followActiveRef.current = true;
|
||||
setIsFollowingBottom(true);
|
||||
let stableFrames = 0;
|
||||
|
||||
const tick = () => {
|
||||
const container = containerRef.current;
|
||||
@@ -97,11 +99,18 @@ export const useScrollEngine = ({
|
||||
|
||||
if (Math.abs(delta) <= SNAP_EPSILON) {
|
||||
container.scrollTop = target;
|
||||
// Don't stop — keep running so next content growth is caught immediately.
|
||||
stableFrames += 1;
|
||||
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
|
||||
followActiveRef.current = false;
|
||||
followRafRef.current = null;
|
||||
setIsFollowingBottom(false);
|
||||
return;
|
||||
}
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
stableFrames = 0;
|
||||
container.scrollTop = current + delta * LERP_FACTOR;
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ interface ServerSnapshotResponse {
|
||||
|
||||
const IMMEDIATE_POLL_DELAY_MS = 150;
|
||||
const FOLLOW_UP_POLL_DELAY_MS = 1100;
|
||||
const MIN_IMMEDIATE_POLL_GAP_MS = 1200;
|
||||
const FOLLOW_UP_REARM_COOLDOWN_MS = 5000;
|
||||
|
||||
// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll
|
||||
let triggerImmediatePollRef: (() => void) | null = null;
|
||||
@@ -50,6 +52,8 @@ export function useServerSessionStatus(options?: { enabled?: boolean }) {
|
||||
const isSyncingRef = React.useRef(false);
|
||||
const hasPendingImmediateSyncRef = React.useRef(false);
|
||||
const lastSyncAtRef = React.useRef(0);
|
||||
const lastImmediatePollRequestAtRef = React.useRef(0);
|
||||
const lastFollowUpPollRequestAtRef = React.useRef(0);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const followUpTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -58,6 +62,9 @@ export function useServerSessionStatus(options?: { enabled?: boolean }) {
|
||||
if (!immediate && now - lastSyncAtRef.current < 1000) {
|
||||
return;
|
||||
}
|
||||
if (immediate && now - lastSyncAtRef.current < 600) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent syncs; if an immediate sync is requested while running,
|
||||
// queue one more pass right after current request settles.
|
||||
@@ -239,24 +246,31 @@ export function useServerSessionStatus(options?: { enabled?: boolean }) {
|
||||
|
||||
// Function to trigger immediate snapshot sync from external modules
|
||||
const triggerImmediatePoll = React.useCallback(() => {
|
||||
// Clear any pending timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (followUpTimeoutRef.current) {
|
||||
clearTimeout(followUpTimeoutRef.current);
|
||||
}
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastImmediatePollRequestAtRef.current;
|
||||
lastImmediatePollRequestAtRef.current = now;
|
||||
|
||||
// Schedule immediate sync with small delay to batch rapid calls
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void fetchSessionStatus(true);
|
||||
}, IMMEDIATE_POLL_DELAY_MS);
|
||||
if (!timeoutRef.current) {
|
||||
const minGapDelay = elapsed >= MIN_IMMEDIATE_POLL_GAP_MS
|
||||
? IMMEDIATE_POLL_DELAY_MS
|
||||
: Math.max(IMMEDIATE_POLL_DELAY_MS, MIN_IMMEDIATE_POLL_GAP_MS - elapsed);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
void fetchSessionStatus(true);
|
||||
}, minGapDelay);
|
||||
}
|
||||
|
||||
// Run one follow-up sync after short settle period to catch delayed
|
||||
// server status transitions that happen right after reconnect/restore.
|
||||
followUpTimeoutRef.current = setTimeout(() => {
|
||||
void fetchSessionStatus(true);
|
||||
}, FOLLOW_UP_POLL_DELAY_MS);
|
||||
// Re-arm at most once per cooldown window to avoid stacked follow-ups.
|
||||
if (!followUpTimeoutRef.current && now - lastFollowUpPollRequestAtRef.current >= FOLLOW_UP_REARM_COOLDOWN_MS) {
|
||||
lastFollowUpPollRequestAtRef.current = now;
|
||||
followUpTimeoutRef.current = setTimeout(() => {
|
||||
followUpTimeoutRef.current = null;
|
||||
void fetchSessionStatus(true);
|
||||
}, FOLLOW_UP_POLL_DELAY_MS);
|
||||
}
|
||||
}, [fetchSessionStatus]);
|
||||
|
||||
// Initial snapshot sync on mount
|
||||
|
||||
Reference in New Issue
Block a user