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
@@ -97,6 +97,7 @@ describe("agent timeline state", () => {
head: [],
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
@@ -108,6 +109,7 @@ describe("agent timeline state", () => {
items,
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
newer: "none",
});
});
@@ -118,13 +120,14 @@ describe("agent timeline state", () => {
head: [],
range: null,
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({ status: "synced", items: [], range: null, older: "none" });
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
});
it("preserves older availability when an applied response leaves it unchanged", () => {
@@ -135,6 +138,7 @@ describe("agent timeline state", () => {
head: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
@@ -144,6 +148,7 @@ describe("agent timeline state", () => {
head: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "unchanged",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: [],
});
@@ -155,6 +160,7 @@ describe("agent timeline state", () => {
items: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "available",
newer: "none",
});
});
+34
View File
@@ -336,6 +336,11 @@ export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
retainedRanges?: Array<{
startSeq: number;
endSeq: number;
hasOlder?: boolean;
}>;
}
export interface SessionReplicaTimeline {
@@ -358,6 +363,7 @@ export type AgentTimelineState =
items: StreamItem[];
range: AgentTimelineCursorState | null;
older: "available" | "none";
newer: "available" | "none";
};
export function selectAgentTimelineState(
@@ -372,6 +378,7 @@ export function selectAgentTimelineState(
items,
range: session.agentTimelineCursor.get(agentId) ?? null,
older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none",
newer: session.agentTimelineHasNewer.get(agentId) === true ? "available" : "none",
};
}
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
@@ -423,6 +430,7 @@ export interface SessionState {
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineHasNewer: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
@@ -556,6 +564,10 @@ interface SessionStoreActions {
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineHasNewer: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineOlderFetchInFlight: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
@@ -575,6 +587,7 @@ interface SessionStoreActions {
head: StreamItem[];
range: AgentTimelineCursorState | null;
older: "available" | "none" | "unchanged";
newer: boolean;
synchronized: boolean;
acknowledgedClientMessageIds: string[];
},
@@ -683,6 +696,7 @@ function createInitialSessionState(
messageSubmissions: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineHasNewer: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
@@ -1432,6 +1446,23 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentTimelineHasNewer: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const nextState =
typeof state === "function" ? state(session.agentTimelineHasNewer) : state;
if (session.agentTimelineHasNewer === nextState) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineHasNewer: nextState },
},
};
});
},
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
@@ -1548,6 +1579,8 @@ export const useSessionStore = create<SessionStore>()(
if (state.older !== "unchanged") {
nextHasOlder.set(agentId, state.older === "available");
}
const nextHasNewer = new Map(session.agentTimelineHasNewer);
nextHasNewer.set(agentId, state.newer);
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
@@ -1579,6 +1612,7 @@ export const useSessionStore = create<SessionStore>()(
agentStreamHead: nextHead,
agentTimelineCursor: nextCursor,
agentTimelineHasOlder: nextHasOlder,
agentTimelineHasNewer: nextHasNewer,
agentAuthoritativeHistoryApplied: nextAuthoritative,
agentHistorySyncGeneration: nextSyncGeneration,
messageSubmissions,