diff --git a/src/gateway/server-methods/chat-history-pages.ts b/src/gateway/server-methods/chat-history-pages.ts index 1475059d6875..1cb19941f0d9 100644 --- a/src/gateway/server-methods/chat-history-pages.ts +++ b/src/gateway/server-methods/chat-history-pages.ts @@ -407,11 +407,9 @@ export async function readChatHistoryPage(params: { max, Math.max(readPage.messages.length, readPage.totalMessages > pageOffset ? 1 : 0), ); - const rawMessages = localMessages; - const recencyFilteredMessages = dropPreSessionStartAnnouncePairs( - rawMessages, - typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined, - ); + // localMessages is already announce-filtered above; the filter is + // single-pass complete, so no second pass is needed. + const recencyFilteredMessages = localMessages; const projected = isTailPage ? projectRecentChatDisplayMessages(recencyFilteredMessages, { maxChars: effectiveMaxChars, @@ -539,15 +537,9 @@ export async function readChatHistoryPage(params: { }, }; } - const rawMessages = cliHistory.messages; - // Drop subagent_announce pairs (user inter-session announce + adjacent - // assistant) whose record timestamp predates the current session's - // sessionStartedAt. Run after CLI history imports too, because those - // timestamped messages share the same chat.history response surface. - const recencyFilteredMessages = dropPreSessionStartAnnouncePairs( - rawMessages, - typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined, - ); + // The imported case returned above, so these are the already announce-filtered + // local messages; the filter is single-pass complete, so no second pass is needed. + const recencyFilteredMessages = cliHistory.messages; const displayMessages = projectRecentChatDisplayMessages(recencyFilteredMessages, { maxChars: effectiveMaxChars, maxMessages: max, diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts index a60492c035b3..e59f997938fe 100644 --- a/src/gateway/server-methods/sessions-rewind.test.ts +++ b/src/gateway/server-methods/sessions-rewind.test.ts @@ -454,21 +454,21 @@ describe("session message-cut methods", () => { ); }); - it("rejects externally owned conversations", async () => { + it("rejects mutation but lists empty branches for externally owned conversations", async () => { linkToUpstreamConversation(); const respond = await invoke("sessions.branches.switch", "off-path-entry"); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("external agent harness"), + }), + ); + // Listing is read-only: "no local branches" is the truthful steady state, + // not an error to latch into the UI. const listed = await invoke("sessions.branches.list"); - - for (const response of [respond, listed]) { - expect(response).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: expect.stringContaining("external agent harness"), - }), - ); - } + expect(listed).toHaveBeenCalledWith(true, { branches: [] }, undefined); }); it.each(["sessions.rewind", "sessions.branches.switch"] as const)( diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 9b646b54ea7a..d74f557101c1 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -177,7 +177,9 @@ async function listBranches(options: GatewayRequestHandlerOptions): Promise { expect(state.chatBranchesConnectionEpoch).toBe(state.connectionEpoch); }); + it("retries the branch list on the next history load after a transient failure", async () => { + const state = createState({ messages: [] }) as TestState & { + sessions: { listBranches: ReturnType }; + }; + state.sessions = { + listBranches: vi + .fn() + .mockRejectedValueOnce(new Error("gateway hiccup")) + .mockResolvedValue([ + { leafEntryId: "tip", headline: "tip", messageCount: 1, active: true }, + ]), + setModelOverride: vi.fn(), + }; + + await loadChatHistory(state); + // The transient failure must not latch success state; the next load retries. + expect(state.chatBranchesSessionKey ?? null).toBeNull(); + + await loadChatHistory(state); + expect(state.sessions.listBranches).toHaveBeenCalledTimes(2); + expect(state.chatBranchesSessionKey).toBe(state.sessionKey); + expect(state.chatBranches).toHaveLength(1); + }); + + it("treats the legacy main alias and canonical key as the same branch owner", async () => { + const state = createState({ messages: [] }) as TestState & { + sessions: { listBranches: ReturnType }; + }; + state.sessionKey = "main"; + state.chatBranchesSessionKey = "agent:main:main"; + state.chatBranchesConnectionEpoch = state.connectionEpoch; + state.sessions = { listBranches: vi.fn().mockResolvedValue([]), setModelOverride: vi.fn() }; + + await loadChatHistory(state); + + // Equivalent spellings must not force a redundant branch reload. + expect(state.sessions.listBranches).not.toHaveBeenCalled(); + }); + it("starts a fresh snapshot and rejects in-flight history after a same-key branch switch", async () => { let resolvePreviousHistory!: (result: ChatHistoryResult) => void; const previousHistory = new Promise((resolve) => { diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 8e2601a33975..844acbc791b6 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -311,7 +311,6 @@ export type ChatState = { chatBranches?: SessionBranch[]; chatBranchesSessionKey?: string | null; chatBranchesConnectionEpoch?: number | null; - chatBranchesLoading?: boolean; requestUpdate?: () => void; }; @@ -1371,6 +1370,15 @@ export async function switchChatHistoryBranch( } } +/** Branches for the current pane; equivalence covers alias-canonicalization windows (#124020 class). */ +export function displayedChatSessionBranches( + state: Pick, +): SessionBranch[] { + return areUiSessionKeysEquivalent(state.chatBranchesSessionKey, state.sessionKey) + ? (state.chatBranches ?? []) + : []; +} + export async function loadChatBranches(state: ChatState): Promise { const sessions = state.sessions; const client = state.client; @@ -1388,7 +1396,6 @@ export async function loadChatBranches(state: ChatState): Promise { const version = ++requests.branchVersion; const connectionEpoch = state.connectionEpoch; const agentParams = scopedAgentParamsForSession(state, sessionKey); - state.chatBranchesLoading = true; try { const branches = await sessions.listBranches(sessionKey, agentParams); if ( @@ -1404,19 +1411,11 @@ export async function loadChatBranches(state: ChatState): Promise { state.chatBranchesSessionKey = sessionKey; state.chatBranchesConnectionEpoch = connectionEpoch; } catch { - if ( - requests.branchVersion === version && - state.client === client && - state.connectionEpoch === connectionEpoch && - visibleSessionMatches(state, sessionKey, agentParams.agentId) - ) { - state.chatBranches = []; - state.chatBranchesSessionKey = sessionKey; - state.chatBranchesConnectionEpoch = connectionEpoch; - } + // Leave chatBranchesSessionKey unset so the next history load retries; + // recording success here latched transient failures into a permanently + // hidden branch dropdown with no visible outcome. } finally { if (requests.branchVersion === version) { - state.chatBranchesLoading = false; state.requestUpdate?.(); } } @@ -1452,7 +1451,7 @@ export async function loadChatHistory( } if ( opts.deferBranches !== true && - (state.chatBranchesSessionKey !== sessionKey || + (!areUiSessionKeysEquivalent(state.chatBranchesSessionKey, sessionKey) || state.chatBranchesConnectionEpoch !== connectionEpoch) ) { void loadChatBranches(state); @@ -1662,7 +1661,7 @@ async function loadChatHistoryUncached( }, ); if (Object.hasOwn(res.sessionInfo ?? {}, "activeLeafEntryId")) { - state.chatDisplayedLeafEntryId = res.sessionInfo?.activeLeafEntryId?.trim() || null; + state.chatDisplayedLeafEntryId = nextDisplayedLeafEntryId; } retirePersistedSteeredChips(state); state.chatHistoryPagination = reconciledHistory?.pagination ?? nextPagination; diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index c164e045bac0..a85a158e01d2 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -28,6 +28,7 @@ import { } from "../../lib/sessions/session-key.ts"; import { isActiveTask } from "../../lib/tasks/data.ts"; import { renderBoardViewSwitch } from "./board-session-surface.ts"; +import { displayedChatSessionBranches } from "./chat-history.ts"; import { resolveChatPaneDesktopTarget, resolveChatPanePlacement } from "./chat-pane-placement.ts"; import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; @@ -366,10 +367,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { workspaceIcon: this.resolveWorkspaceIcon(workspace.root ? row?.key : undefined), parentSession: resolveChatPaneParentSession(row, this.state?.sessionsResult?.sessions ?? []), branch, - branches: - this.state && this.state.chatBranchesSessionKey === this.state.sessionKey - ? (this.state.chatBranches ?? []) - : [], + branches: this.state ? displayedChatSessionBranches(this.state) : [], branchSwitchDisabledReason, platform: this.headerPlatform, canReveal, diff --git a/ui/src/pages/chat/chat-state-events.ts b/ui/src/pages/chat/chat-state-events.ts index 1c29a2934605..78e1768670d6 100644 --- a/ui/src/pages/chat/chat-state-events.ts +++ b/ui/src/pages/chat/chat-state-events.ts @@ -202,7 +202,6 @@ function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) { // replaces it in place instead of appending below the newer user turn. applyLiveSessionMessage(state, payload, event.hasActiveRun ?? undefined); retirePersistedSteeredChips(state); - void loadChatBranches(state); } if (matchesChat && event.archived !== null) { state.selectedChatSessionArchived = event.archived; @@ -262,6 +261,11 @@ function replayPendingSessionMessageReload( void loadChatHistory(state).finally(() => state.requestUpdate?.()); } +// Branch topology only changes on structural mutations; the producer records +// the reason, so reload branches only for those instead of on every +// sessions.changed (each cache miss rescans the full transcript on the gateway). +const BRANCH_TOPOLOGY_REASONS = new Set(["rewind", "branch-switch", "fork", "reset", "new"]); + function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { const runIdBeforeApply = state.chatRunId; const event = readSessionChangedEvent(payload); @@ -280,7 +284,11 @@ function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { // only proof that its old live and pending transcript no longer exists. reduceChatSessionProjection(state, { type: "sessionReset" }, { scope }); } - if (matchesChat) { + if ( + matchesChat && + typeof source?.reason === "string" && + BRANCH_TOPOLOGY_REASONS.has(source.reason) + ) { void loadChatBranches(state); } if (event && matchesChat && event.archived !== null) { diff --git a/ui/src/pages/chat/chat-state-page.ts b/ui/src/pages/chat/chat-state-page.ts index 90564ee3df4f..6929deaced5b 100644 --- a/ui/src/pages/chat/chat-state-page.ts +++ b/ui/src/pages/chat/chat-state-page.ts @@ -170,7 +170,6 @@ export function createPageState( chatBranches: [], chatBranchesSessionKey: null, chatBranchesConnectionEpoch: null, - chatBranchesLoading: false, chatToolMessages: [], chatThinkingLevel: null, chatVerboseLevel: null,