feat(app): add chat outline navigation (#2792)

* feat(app): add chat outline navigation

Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes.

* fix(app): preserve chat outline timeline coverage

* fix(app): reconcile outline window submissions

* fix(app): harden outline timeline navigation
This commit is contained in:
Mohamed Boudra
2026-08-02 20:48:23 +08:00
committed by GitHub
parent 0ad51e26a1
commit 553ac53643
49 changed files with 3385 additions and 84 deletions
+54 -4
View File
@@ -54,6 +54,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
@@ -66,6 +67,10 @@ import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/vi
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import {
CompletedTurnFooterRow,
TurnFooter,
@@ -155,7 +160,9 @@ function renderStreamItemWithTurnFooter(input: {
/>
) : null;
const content = (
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
{input.content}
</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
@@ -287,6 +294,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const { t } = useTranslation();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
@@ -324,6 +332,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
);
const supportsChatOutline = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true,
);
const timelineEpoch = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
);
const isTimelineDetached = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
@@ -516,6 +534,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamRenderStrategy,
],
);
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
timelineEpoch,
tail: effectiveStreamItems,
head: effectiveStreamHead,
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
});
useImperativeHandle(
ref,
() => ({
@@ -530,8 +562,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const scrollToBottom = useCallback(() => {
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
if (!isTimelineDetached) {
viewportRef.current?.scrollToBottom("jump-to-bottom");
return;
}
void returnToTimelineTail({
fetchTail: () =>
getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, {
...planTimelineTailFetch(),
}),
scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"),
onError: handleTimelineHistoryLoadError,
});
}, [agentId, handleTimelineHistoryLoadError, isTimelineDetached, resolvedServerId]);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
@@ -947,6 +990,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: chatOutline.reportReadingPosition,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
@@ -957,7 +1001,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
})}
</MessageOuterSpacingProvider>
{!isNearBottom && (
<ChatOutlineRail
prompts={chatOutline.prompts}
activePrompt={chatOutline.activePrompt}
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
@@ -1559,6 +1608,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
}));
interface StreamItemWrapperProps {
itemId: string;
gapBelow: number;
children: ReactNode;
}