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:
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user