Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 57 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ const SCRIPT_TERMINAL_ROWS = 30;
const WORKTREE_BRANCH_PREFIX = "t3code";
const HEADER_COMPACT_BREAKPOINT = 480;
const DESKTOP_APP_RESOLUTION_TIMEOUT_MS = 2_500;
const POINTER_SCROLL_INTENT_THRESHOLD_PX = 6;

function isTerminalUserInputSubmitFailure(error: unknown): boolean {
const message =
Expand Down Expand Up @@ -1807,6 +1808,7 @@ export default function ChatView({
const shouldAutoScrollRef = useRef(true);
const lastKnownScrollTopRef = useRef(0);
const isPointerScrollActiveRef = useRef(false);
const pointerScrollStartYRef = useRef<number | null>(null);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingUserScrollUpIntentRef = useRef(false);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -1947,6 +1949,12 @@ export default function ChatView({
activeThread.session !== null),
);
const shouldShowNewThreadLanding = isLocalDraftThread && !hasThreadStarted;
const shouldShowThreadBodyLoading =
activeThread !== undefined &&
isServerThread &&
activeThread.latestTurn !== null &&
activeThread.messages.length === 0 &&
optimisticUserMessages.length === 0;
const isPromptEmpty =
prompt.trim().length === 0 &&
selectedComposerExtensions.length === 0 &&
Expand Down Expand Up @@ -3500,9 +3508,11 @@ export default function ChatView({
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const scrollContainer = messagesScrollRef.current;
if (!scrollContainer) return;
const bottomScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
lastKnownScrollTopRef.current = bottomScrollTop;
shouldAutoScrollRef.current = true;
pendingUserScrollUpIntentRef.current = false;
setShowScrollToBottomPill(false);
}, []);
const cancelPendingStickToBottom = useCallback(() => {
Expand Down Expand Up @@ -3572,42 +3582,52 @@ export default function ChatView({
if (!shouldAutoScrollRef.current && isNearBottom) {
shouldAutoScrollRef.current = true;
pendingUserScrollUpIntentRef.current = false;
} else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
if (scrolledUp) {
shouldAutoScrollRef.current = false;
}
} else if (
shouldAutoScrollRef.current &&
!isNearBottom &&
(pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current)
) {
shouldAutoScrollRef.current = false;
pendingUserScrollUpIntentRef.current = false;
} else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
if (scrolledUp) {
shouldAutoScrollRef.current = false;
}
cancelPendingStickToBottom();
} else if (shouldAutoScrollRef.current && !isNearBottom) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Catch-all for keyboard/assistive scroll interactions.
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
if (scrolledUp) {
shouldAutoScrollRef.current = false;
cancelPendingStickToBottom();
}
}

lastKnownScrollTopRef.current = currentScrollTop;
setShowScrollToBottomPill((current) => (current === !isNearBottom ? current : !isNearBottom));
}, []);
const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
}
}, []);
const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
isPointerScrollActiveRef.current = true;
}, []);
const onMessagesPointerUp = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
}, [cancelPendingStickToBottom]);
const onMessagesWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
}
},
[cancelPendingStickToBottom],
);
const clearPointerScrollIntent = useCallback(() => {
pointerScrollStartYRef.current = null;
isPointerScrollActiveRef.current = false;
}, []);
const onMessagesPointerCancel = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
const onMessagesPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
pointerScrollStartYRef.current = event.clientY;
isPointerScrollActiveRef.current = false;
}, []);
const onMessagesPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const startY = pointerScrollStartYRef.current;
if (startY === null) return;
if (Math.abs(event.clientY - startY) >= POINTER_SCROLL_INTENT_THRESHOLD_PX) {
isPointerScrollActiveRef.current = true;
}
}, []);
const onMessagesPointerUp = clearPointerScrollIntent;
const onMessagesPointerCancel = clearPointerScrollIntent;
const onMessagesTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
if (!touch) return;
Expand Down Expand Up @@ -5249,7 +5269,10 @@ export default function ChatView({
)
.then(() => api.orchestration.getSnapshot({ mode: "focused", threadId: nextThreadId }))
.then((snapshot) => {
syncServerReadModel(snapshot);
syncServerReadModel(snapshot, {
authoritativeThreadDetailIds: new Set([nextThreadId]),
preserveThreadDetails: true,
});
return navigate({
to: "/$threadId",
params: { threadId: nextThreadId },
Expand All @@ -5266,7 +5289,7 @@ export default function ChatView({
await api.orchestration
.getSnapshot({ mode: "bootstrap" })
.then((snapshot) => {
syncServerReadModel(snapshot);
syncServerReadModel(snapshot, { preserveThreadDetails: true });
})
.catch(() => undefined);
toastManager.add({
Expand Down Expand Up @@ -5867,7 +5890,7 @@ export default function ChatView({

const snapshot = await api.orchestration.getSnapshot({ mode: "bootstrap" }).catch(() => null);
if (snapshot) {
syncServerReadModel(snapshot);
syncServerReadModel(snapshot, { preserveThreadDetails: true });
}

await onSelectNewThreadLandingProject(projectId);
Expand Down Expand Up @@ -6328,8 +6351,10 @@ export default function ChatView({
onClickCapture={onMessagesClickCapture}
onWheel={onMessagesWheel}
onPointerDown={onMessagesPointerDown}
onPointerMove={onMessagesPointerMove}
onPointerUp={onMessagesPointerUp}
onPointerCancel={onMessagesPointerCancel}
onPointerLeave={onMessagesPointerCancel}
onTouchStart={onMessagesTouchStart}
onTouchMove={onMessagesTouchMove}
onTouchEnd={onMessagesTouchEnd}
Expand Down Expand Up @@ -6393,6 +6418,13 @@ export default function ChatView({
) : null}
</div>
</div>
) : shouldShowThreadBodyLoading ? (
<div
className="flex min-h-full items-center justify-center px-4 text-center text-sm text-muted-foreground/70"
data-testid="chat-thread-body-loading"
>
Loading conversation...
</div>
) : (
<MessagesTimeline
key={activeThread.id}
Expand Down
119 changes: 117 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, React.FC<React.SVGProps<SV

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const THREAD_PREVIEW_LIMIT = 6;
const THREAD_DETAIL_PREFETCH_DELAY_MS = 160;

function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback;
Expand Down Expand Up @@ -867,6 +868,7 @@ export default function Sidebar() {
const threads = useStore((store) => store.threads);
const threadsHydrated = useStore((store) => store.threadsHydrated);
const hydrationError = useStore((store) => store.hydrationError);
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const markThreadUnread = useStore((store) => store.markThreadUnread);
const toggleProject = useStore((store) => store.toggleProject);
const setAllProjectsExpanded = useStore((store) => store.setAllProjectsExpanded);
Expand Down Expand Up @@ -925,6 +927,99 @@ export default function Sidebar() {
const [draggedProjectId, setDraggedProjectId] = useState<ProjectId | null>(null);
const [dropTargetProjectId, setDropTargetProjectId] = useState<ProjectId | null>(null);
const [dropTargetPosition, setDropTargetPosition] = useState<"before" | "after" | null>(null);
const threadDetailPrefetchTimeoutsRef = useRef(new Map<ThreadId, number>());
const threadDetailPrefetchInFlightRef = useRef(new Set<ThreadId>());
const threadDetailPrefetchKeyByThreadIdRef = useRef(new Map<ThreadId, string>());

const cancelThreadDetailPrefetch = useCallback((threadId: ThreadId) => {
const timeoutId = threadDetailPrefetchTimeoutsRef.current.get(threadId);
if (timeoutId === undefined) {
return;
}
window.clearTimeout(timeoutId);
threadDetailPrefetchTimeoutsRef.current.delete(threadId);
}, []);

const scheduleThreadDetailPrefetch = useCallback(
(thread: Thread) => {
if (
thread.id === routeThreadId ||
thread.archivedAt !== null ||
thread.latestTurn === null ||
thread.messages.length > 0
) {
return;
}

const prefetchKey = [
thread.id,
thread.updatedAt,
thread.latestTurn.turnId,
thread.latestTurn.completedAt ?? "",
].join("\u0000");
if (threadDetailPrefetchKeyByThreadIdRef.current.get(thread.id) === prefetchKey) {
return;
}
if (threadDetailPrefetchInFlightRef.current.has(thread.id)) {
return;
}
if (threadDetailPrefetchTimeoutsRef.current.has(thread.id)) {
return;
}

const timeoutId = window.setTimeout(() => {
threadDetailPrefetchTimeoutsRef.current.delete(thread.id);
const currentThread = useStore
.getState()
.threads.find((candidate) => candidate.id === thread.id);
if (
currentThread === undefined ||
currentThread.archivedAt !== null ||
currentThread.latestTurn === null ||
currentThread.messages.length > 0
) {
return;
}

const api = readNativeApi();
if (!api) {
return;
}

threadDetailPrefetchInFlightRef.current.add(thread.id);
void api.orchestration
.getSnapshot({
mode: "focused",
threadId: thread.id,
threadIds: [thread.id],
})
.then((snapshot) => {
syncServerReadModel(snapshot, {
authoritativeThreadDetailIds: new Set([thread.id]),
preserveThreadDetails: true,
});
threadDetailPrefetchKeyByThreadIdRef.current.set(thread.id, prefetchKey);
})
.catch(() => undefined)
.finally(() => {
threadDetailPrefetchInFlightRef.current.delete(thread.id);
});
}, THREAD_DETAIL_PREFETCH_DELAY_MS);

threadDetailPrefetchTimeoutsRef.current.set(thread.id, timeoutId);
},
[routeThreadId, syncServerReadModel],
);

useEffect(
() => () => {
for (const timeoutId of threadDetailPrefetchTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
threadDetailPrefetchTimeoutsRef.current.clear();
},
[],
);

const openSearchPalette = useCallback((mode: SidebarSearchPaletteMode = "search") => {
setSearchPaletteMode(mode);
Expand Down Expand Up @@ -2569,12 +2664,20 @@ export default function Sidebar() {
<RowWrapper
className="group/thread-row relative w-full"
data-thread-item
onMouseLeave={() => clearArchiveConfirm(thread.id)}
onMouseEnter={() => scheduleThreadDetailPrefetch(thread)}
onMouseLeave={() => {
cancelThreadDetailPrefetch(thread.id);
clearArchiveConfirm(thread.id);
}}
onFocusCapture={() => {
scheduleThreadDetailPrefetch(thread);
}}
onBlurCapture={(event: FocusEvent<HTMLElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}
cancelThreadDetailPrefetch(thread.id);
clearArchiveConfirm(thread.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}}
>
Expand Down Expand Up @@ -2860,6 +2963,7 @@ export default function Sidebar() {
[
archiveConfirmThreadId,
cancelRename,
cancelThreadDetailPrefetch,
clearArchiveConfirm,
commitRename,
handleInlineArchiveConfirm,
Expand All @@ -2873,6 +2977,7 @@ export default function Sidebar() {
renamingThreadId,
renamingTitle,
routeThreadId,
scheduleThreadDetailPrefetch,
sidebarPreferences.threadSort,
expandedSubagentParentIds,
draftThreadsByThreadId,
Expand Down Expand Up @@ -3121,12 +3226,20 @@ export default function Sidebar() {
<SidebarMenuItem
className="group/pinned-thread relative w-full"
data-thread-item
onMouseLeave={() => clearArchiveConfirm(thread.id)}
onMouseEnter={() => scheduleThreadDetailPrefetch(thread)}
onMouseLeave={() => {
cancelThreadDetailPrefetch(thread.id);
clearArchiveConfirm(thread.id);
}}
onFocusCapture={() => {
scheduleThreadDetailPrefetch(thread);
}}
onBlurCapture={(event: FocusEvent<HTMLElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}
cancelThreadDetailPrefetch(thread.id);
clearArchiveConfirm(thread.id);
}}
>
Expand Down Expand Up @@ -3283,6 +3396,7 @@ export default function Sidebar() {
},
[
archiveConfirmThreadId,
cancelThreadDetailPrefetch,
clearArchiveConfirm,
handleInlineArchiveConfirm,
expandedSubagentParentIds,
Expand All @@ -3292,6 +3406,7 @@ export default function Sidebar() {
pendingApprovalByThreadId,
pendingUserInputByThreadId,
routeThreadId,
scheduleThreadDetailPrefetch,
toggleSubagentParent,
],
);
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3691,11 +3691,19 @@ export const MessagesTimeline = memo(function MessagesTimeline(props: MessagesTi
}, [rowVirtualizer, timelineWidthPx]);

useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
const previousTotalSize = instance.getTotalSize() - delta;
const remainingDistance = previousTotalSize - (scrollOffset + viewportHeight);
const isReadingOlderHistory = remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
const changedItemIsAboveViewport = item.start < scrollOffset;

return (
isReadingOlderHistory &&
changedItemIsAboveViewport &&
instance.scrollDirection !== "backward"
);
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useDisposableThreadLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function useDisposableThreadLifecycle(activeThreadId: ThreadId | null): v
.getSnapshot({ mode: "bootstrap" })
.catch(() => null);
if (snapshot) {
syncServerReadModel(snapshot);
syncServerReadModel(snapshot, { preserveThreadDetails: true });
}
}
}
Expand Down
Loading
Loading