diff --git a/desktop/app.go b/desktop/app.go index 08132d84ac..6115601ee8 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -2236,6 +2236,33 @@ func (a *App) ResumeSession(path string) ([]HistoryMessage, error) { return a.ResumeSessionForTab("", path) } +func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) { + return a.ResumeSessionPageForTab("", path, limit) +} + +func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) { + tab := a.tabByID(tabID) + if tab == nil || tab.Ctrl == nil { + return HistoryPage{}, fmt.Errorf("tab is not ready") + } + ctrl := tab.Ctrl + sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) + if err != nil { + return HistoryPage{}, err + } + loaded, err := loadResumableSession(sessionPath) + if err != nil { + return HistoryPage{}, err + } + if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { + if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { + return HistoryPage{}, err + } + } + a.setTabReadOnly(tab.ID, false) + return a.HistoryPageForTab(tab.ID, 0, limit), nil +} + // ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session // path is a runtime identity, so changing to a different path must replace the // tab's controller binding rather than mutating the current controller in place. @@ -2288,6 +2315,29 @@ func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, er return a.HistoryForTab(tab.ID), nil } +func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) { + tab := a.tabByID(tabID) + if tab == nil || tab.Ctrl == nil { + return HistoryPage{}, fmt.Errorf("tab is not ready") + } + ctrl := tab.Ctrl + sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) + if err != nil { + return HistoryPage{}, err + } + loaded, err := loadResumableSession(sessionPath) + if err != nil { + return HistoryPage{}, err + } + if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { + if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { + return HistoryPage{}, err + } + } + a.setTabReadOnly(tab.ID, true) + return a.HistoryPageForTab(tab.ID, 0, limit), nil +} + func (a *App) setTabReadOnly(tabID string, readOnly bool) { a.mu.Lock() if tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly { @@ -3152,11 +3202,121 @@ type HistoryToolCall struct { ArgumentsArchived bool `json:"argumentsArchived,omitempty"` } +const ( + defaultHistoryPageTurns = 60 + maxHistoryPageTurns = 200 +) + +type HistoryPage struct { + Messages []HistoryMessage `json:"messages"` + StartTurn int `json:"startTurn"` + EndTurn int `json:"endTurn"` + TotalTurns int `json:"totalTurns"` + HasOlder bool `json:"hasOlder"` +} + // History returns the session's message log. func (a *App) History() []HistoryMessage { return a.HistoryForTab("") } +func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage { + return a.HistoryPageForTab("", beforeTurn, limit) +} + +func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage { + a.mu.RLock() + tab := a.tabByIDLocked(tabID) + var ctrl control.SessionAPI + var sessionDir, sessionPath string + if tab != nil { + ctrl = tab.Ctrl + sessionDir = tabSessionDir(tab) + sessionPath = tab.currentSessionPath() + } + a.mu.RUnlock() + if ctrl == nil { + if strings.TrimSpace(sessionPath) == "" { + return HistoryPage{Messages: []HistoryMessage{}} + } + page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit) + if err != nil { + return HistoryPage{Messages: []HistoryMessage{}} + } + return page + } + msgs := ctrl.History() + dir := controllerSessionDir(ctrl) + path := ctrl.SessionPath() + return historyPageFromProviderMessages( + msgs, + sessionDisplayResolver(dir, path), + sessionPlannerDisplayTurns(dir, path), + ctrl.CheckpointTurnsByMessageIndex(), + beforeTurn, + limit, + ) +} + +func normalizeHistoryPageLimit(limit int) int { + if limit <= 0 { + return defaultHistoryPageTurns + } + if limit > maxHistoryPageTurns { + return maxHistoryPageTurns + } + return limit +} + +func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage { + limit = normalizeHistoryPageLimit(limit) + totalTurns := 0 + for _, msg := range messages { + if msg.Role == "user" { + totalTurns++ + } + } + if beforeTurn <= 0 || beforeTurn > totalTurns { + beforeTurn = totalTurns + } + startTurn := beforeTurn - limit + if startTurn < 0 { + startTurn = 0 + } + page := HistoryPage{ + StartTurn: startTurn, + EndTurn: beforeTurn, + TotalTurns: totalTurns, + HasOlder: startTurn > 0, + } + if len(messages) == 0 || startTurn >= beforeTurn { + page.Messages = []HistoryMessage{} + return page + } + page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn) + return page +} + +func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage { + out := make([]HistoryMessage, 0, len(messages)) + turn := -1 + for _, msg := range messages { + if msg.Role == "user" { + turn++ + } + if turn < 0 { + if startTurn == 0 { + out = append(out, msg) + } + continue + } + if turn >= startTurn && turn < endTurn { + out = append(out, msg) + } + } + return out +} + func (a *App) HistoryForTab(tabID string) []HistoryMessage { a.mu.RLock() tab := a.tabByIDLocked(tabID) @@ -3189,14 +3349,64 @@ func (a *App) HistoryForTab(tabID string) []HistoryMessage { ) } +func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int { + a.mu.RLock() + tab := a.tabByIDLocked(tabID) + var ctrl control.SessionAPI + if tab != nil { + ctrl = tab.Ctrl + } + a.mu.RUnlock() + if ctrl == nil { + return []int{} + } + return historyCheckpointTurns( + ctrl.History(), + sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()), + ctrl.CheckpointTurnsByMessageIndex(), + ) +} + +func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int { + out := make([]int, 0) + for index, msg := range msgs { + if msg.Role != provider.RoleUser { + continue + } + if _, isSteer := agent.SteerText(msg.Content); isSteer { + continue + } + if control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) { + continue + } + turn, ok := checkpointTurns[index] + if !ok { + turn = -1 + } + out = append(out, turn) + } + return out +} + func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage { return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil) } func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage { - out := make([]HistoryMessage, 0, len(msgs)) replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs) toolResults := historyToolResultsByID(msgs) + return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults) +} + +func historyMessagesWithPlannerDisplaysAndLookups( + msgs []provider.Message, + resolveUserContent func(string) string, + plannerTurns []plannerDisplayTurn, + checkpointTurns map[int]int, + replayedTodoArgs map[string]string, + toolResults map[string]provider.Message, +) []HistoryMessage { + out := make([]HistoryMessage, 0, len(msgs)) plannerByUserHash := plannerTurnsByUserHash(plannerTurns) for index, m := range msgs { content := m.Content @@ -3260,6 +3470,100 @@ func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserCont return out } +func historyPageFromProviderMessages( + msgs []provider.Message, + resolveUserContent func(string) string, + plannerTurns []plannerDisplayTurn, + checkpointTurns map[int]int, + beforeTurn, limit int, +) HistoryPage { + limit = normalizeHistoryPageLimit(limit) + totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent) + if beforeTurn <= 0 || beforeTurn > totalTurns { + beforeTurn = totalTurns + } + startTurn := beforeTurn - limit + if startTurn < 0 { + startTurn = 0 + } + page := HistoryPage{ + StartTurn: startTurn, + EndTurn: beforeTurn, + TotalTurns: totalTurns, + HasOlder: startTurn > 0, + } + if len(msgs) == 0 || startTurn >= beforeTurn { + page.Messages = []HistoryMessage{} + return page + } + pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn) + page.Messages = historyMessagesWithPlannerDisplaysAndLookups( + pageMessages, + resolveUserContent, + plannerTurns, + checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes), + historyTodoArgsWithCompleteSteps(msgs), + historyToolResultsByID(msgs), + ) + return page +} + +func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int { + total := 0 + for _, msg := range msgs { + if isVisibleHistoryUser(msg, resolveUserContent) { + total++ + } + } + return total +} + +func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool { + if msg.Role != provider.RoleUser { + return false + } + if _, isSteer := agent.SteerText(msg.Content); isSteer { + return false + } + return !control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) +} + +func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) { + out := make([]provider.Message, 0, len(msgs)) + indexes := make([]int, 0, len(msgs)) + turn := -1 + for index, msg := range msgs { + if isVisibleHistoryUser(msg, resolveUserContent) { + turn++ + } + if turn < 0 { + if startTurn == 0 { + out = append(out, msg) + indexes = append(indexes, index) + } + continue + } + if turn >= startTurn && turn < endTurn { + out = append(out, msg) + indexes = append(indexes, index) + } + } + return out, indexes +} + +func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int { + if len(checkpointTurns) == 0 || len(originalIndexes) == 0 { + return nil + } + out := map[int]int{} + for pageIndex, originalIndex := range originalIndexes { + if turn, ok := checkpointTurns[originalIndex]; ok { + out[pageIndex] = turn + } + } + return out +} + func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn { out := map[string][]plannerDisplayTurn{} for _, turn := range turns { @@ -3605,6 +3909,31 @@ func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) { ), nil } +func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) { + sessionPath, _, err := validateSessionPath(sessionDir, path) + if err != nil { + return HistoryPage{}, err + } + if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil { + if err != nil { + return HistoryPage{}, err + } + return historyPageFromMessages(out, beforeTurn, limit), nil + } + loaded, err := agent.LoadSession(sessionPath) + if err != nil { + return HistoryPage{}, err + } + return historyPageFromProviderMessages( + loaded.Snapshot(), + sessionDisplayResolver(sessionDir, sessionPath), + sessionPlannerDisplayTurns(sessionDir, sessionPath), + nil, + beforeTurn, + limit, + ), nil +} + type previewEventRecord struct { Kind string `json:"kind"` Type string `json:"type"` diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 56fecd514a..1f0df955d8 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -818,6 +818,7 @@ export default function App() { restoreSession, purgeTrashedSession, renameSession, + loadOlderHistory, refreshMeta, pickWorkspace, switchWorkspace, @@ -3264,6 +3265,10 @@ export default function App() { rewindSignal={rewindSignal} revealSignal={transcriptRevealSignal} hydrating={transcriptHydrating} + hasOlderHistory={state.historyHasOlder && !rewindState} + olderHistoryCount={state.historyStartTurn} + loadingOlderHistory={state.historyOlderLoading} + onLoadOlderHistory={() => activeTabId && loadOlderHistory(activeTabId)} /> )} diff --git a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx index e016189106..1d55b0c2b8 100644 --- a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx +++ b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx @@ -88,10 +88,14 @@ async function renderComposer(props: Partial[0]> = { const calls: { send: string[]; submit: (string | undefined)[]; + cancel: number; + clearGoal: number; setCollaborationMode: CollaborationMode[]; } = { send: [], submit: [], + cancel: 0, + clearGoal: 0, setCollaborationMode: [], }; let currentProps: Parameters[0] = { @@ -106,13 +110,18 @@ async function renderComposer(props: Partial[0]> = { calls.send.push(displayText); calls.submit.push(submitText); }, - onCancel: () => undefined, + onCancel: () => { + calls.cancel += 1; + return undefined; + }, onCycleMode: () => {}, onSetMode: () => {}, onSetCollaborationMode: (mode) => calls.setCollaborationMode.push(mode), onSetToolApprovalMode: () => {}, onToggleYoloApprovalMode: () => {}, - onClearGoal: () => {}, + onClearGoal: () => { + calls.clearGoal += 1; + }, onSwitchModel: () => {}, onSetEffort: () => {}, onSetTokenMode: () => {}, @@ -211,6 +220,32 @@ console.log("\ncomposer goal toggle"); dom.window.close(); } +{ + const dom = installDom(); + const { root, calls } = await renderComposer({ + running: true, + collaborationMode: "goal", + goal: "finish the migration", + turnStartAt: Date.now(), + }); + + const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null; + if (!stopButton) throw new Error("composer stop button did not render"); + + await act(async () => { + stopButton.click(); + await flushTimers(); + }); + + eq(calls.cancel, 1, "goal-mode stop cancels the running turn"); + eq(calls.clearGoal, 1, "goal-mode stop clears the active goal"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + { const dom = installDom(); mockApp({ diff --git a/desktop/frontend/src/__tests__/new-session-load-race.test.tsx b/desktop/frontend/src/__tests__/new-session-load-race.test.tsx index 60aad1217c..c37497eb63 100644 --- a/desktop/frontend/src/__tests__/new-session-load-race.test.tsx +++ b/desktop/frontend/src/__tests__/new-session-load-race.test.tsx @@ -160,6 +160,11 @@ window.go = { JobsForTab: async () => jobs, CheckpointsForTab: async () => checkpoints, HistoryForTab: async () => staleHistory.promise, + HistoryPageForTab: async () => { + const messages = await staleHistory.promise; + return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false }; + }, + HistoryCheckpointTurnsForTab: async () => [], ReplayPendingPrompts: async () => {}, NewSession: async () => { newSessionCalls += 1; @@ -216,6 +221,8 @@ window.go.main.App = { JobsForTab: async () => jobs, CheckpointsForTab: async () => checkpoints, HistoryForTab: async () => [], + HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), + HistoryCheckpointTurnsForTab: async () => [], ReplayPendingPrompts: async () => {}, EnsureBlankSurface: async () => { ensureBlankSurfaceCalls += 1; diff --git a/desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx b/desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx index d70b8a2de6..d06c08861f 100644 --- a/desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx +++ b/desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx @@ -63,6 +63,7 @@ function tabMeta(id: string, overrides: Partial = {}): TabMeta { gitBranch: "main", topicId: `topic-${id}`, topicTitle: id, + sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`, label: `model-${id}`, ready: true, running: false, @@ -169,9 +170,14 @@ window.go = { if (tabID === "tab-d") return historyD.promise; return [userMessage("cached A")]; }, + HistoryPageForTab: async (tabID: string) => { + const messages = await window.go.main.App.HistoryForTab(tabID); + return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false }; + }, + HistoryCheckpointTurnsForTab: async () => [], OpenProjectTab: async () => { backendActiveId = "tab-d"; - return { ...tabD, active: true }; + return { ...(tabsById.get("tab-d") ?? tabD), active: true }; }, NewSession: async () => { newSessionCalls += 1; @@ -240,11 +246,13 @@ await act(async () => { eq(newSessionCalls, 1, "newSession runs after the selected tab is active in the backend"); await waitFor("tab-b history request", () => historyCalls.includes("tab-b")); +const historyCallsBeforeReturnToA = historyCalls.length; await act(async () => { await controller?.switchTab("tab-a", tabA); await flushPromises(); }); await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A")); +eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips history hydration when reselected"); await act(async () => { historyB.resolve([userMessage("late B")]); @@ -295,12 +303,29 @@ eq(controller?.state.items.length, 0, "open topic keeps the new tab transcript e ok(controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "open topic stores previous transcript only as a hydration placeholder"); await act(async () => { - historyD.resolve([]); + historyD.resolve([userMessage("history D")]); await historyD.promise; await flushPromises(); }); -eq(controller?.state.items.length, 0, "empty topic history keeps the real transcript empty"); -eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "empty topic history clears the hydration placeholder"); +ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "topic history replaces the hydration placeholder"); +eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder"); + +const historyCallsBeforeReopenD = historyCalls.length; +await act(async () => { + await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || ""); + await flushPromises(); +}); +eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active"); +ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript"); +eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration"); + +tabsById.set("tab-d", { ...tabD, sessionPath: `${tabD.workspaceRoot}/sessions/next-tab-d.jsonl` }); +const historyCallsBeforeReboundD = historyCalls.length; +await act(async () => { + await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || ""); + await flushPromises(); +}); +eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes"); await act(async () => { root.unmount(); diff --git a/desktop/frontend/src/__tests__/use-controller-cancel-reconcile.test.tsx b/desktop/frontend/src/__tests__/use-controller-cancel-reconcile.test.tsx index 29b7947f84..74e48ba34c 100644 --- a/desktop/frontend/src/__tests__/use-controller-cancel-reconcile.test.tsx +++ b/desktop/frontend/src/__tests__/use-controller-cancel-reconcile.test.tsx @@ -123,6 +123,8 @@ window.go = { JobsForTab: async () => [], CheckpointsForTab: async () => [], HistoryForTab: async () => [], + HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), + HistoryCheckpointTurnsForTab: async () => [], ReplayPendingPrompts: async () => {}, CancelTab: async () => { cancelCalls += 1; diff --git a/desktop/frontend/src/__tests__/use-controller-meta.test.ts b/desktop/frontend/src/__tests__/use-controller-meta.test.ts index 430e13e446..461de508a0 100644 --- a/desktop/frontend/src/__tests__/use-controller-meta.test.ts +++ b/desktop/frontend/src/__tests__/use-controller-meta.test.ts @@ -193,10 +193,7 @@ console.log("\nuse controller meta"); s = reducer(s, { type: "event", e: { kind: "turn_done" } }); const merged = reducer(s, { type: "history_checkpoint_turns", - messages: [ - { role: "user", content: "first", checkpointTurn: 0 }, - { role: "assistant", content: "done" }, - ], + turns: [0], }); const user = merged.items.find((item) => item.kind === "user"); const notice = merged.items.find((item) => item.kind === "notice" && item.text === "runtime notice"); @@ -204,5 +201,52 @@ console.log("\nuse controller meta"); eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices"); } +{ + let s = reducer(initialState, { + type: "history_page", + mode: "replace", + page: { + messages: [ + { role: "user", content: "recent prompt" }, + { role: "assistant", content: "recent answer" }, + ], + startTurn: 60, + endTurn: 61, + totalTurns: 61, + hasOlder: true, + }, + }); + eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window"); + eq(s.historyStartTurn, 60, "history page stores the older cursor"); + eq(s.historyHasOlder, true, "history page records older availability"); + const checkpointed = reducer(s, { + type: "history_checkpoint_turns", + turns: Array.from({ length: 61 }, (_, index) => index + 1000), + }); + const recentUser = checkpointed.items.find((item) => item.kind === "user" && item.text === "recent prompt"); + eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged checkpoint merge uses the window start turn"); + s = reducer(s, { type: "history_older_start" }); + eq(s.historyOlderLoading, true, "older history request marks loading"); + s = reducer(s, { + type: "history_page", + mode: "prepend", + page: { + messages: [ + { role: "user", content: "older prompt" }, + { role: "assistant", content: "older answer" }, + ], + startTurn: 0, + endTurn: 1, + totalTurns: 61, + hasOlder: false, + }, + }); + const users = s.items.filter((item) => item.kind === "user"); + eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window"); + eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window"); + eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded"); + eq(s.historyOlderLoading, false, "older history clears loading"); +} + console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index f566c5a0a8..8d86738ba6 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1371,6 +1371,7 @@ export function Composer({ // replied, the just-sent text is handed back so we drop it back into the input. const handleCancel = () => { const restored = onCancel(); + if (goalModeOn && activeGoal) onClearGoal(); if (typeof restored === "string") setTextCaretEnd(restored); }; diff --git a/desktop/frontend/src/components/Transcript.tsx b/desktop/frontend/src/components/Transcript.tsx index 73e8dafb16..3d506137b9 100644 --- a/desktop/frontend/src/components/Transcript.tsx +++ b/desktop/frontend/src/components/Transcript.tsx @@ -95,6 +95,10 @@ export function Transcript({ rewindSignal = 0, revealSignal = 0, hydrating = false, + hasOlderHistory = false, + olderHistoryCount = 0, + loadingOlderHistory = false, + onLoadOlderHistory, }: { items: Item[]; live?: LiveStream; @@ -114,6 +118,10 @@ export function Transcript({ rewindSignal?: number; revealSignal?: number; hydrating?: boolean; + hasOlderHistory?: boolean; + olderHistoryCount?: number; + loadingOlderHistory?: boolean; + onLoadOlderHistory?: () => void; }) { const t = useT(); const { @@ -124,7 +132,7 @@ export function Transcript({ smoothScrollTo, scrollToBottomAfterLayout, trackQuestions, - repinIfWasPinned, + scheduleRepinIfWasPinned, resizeFrame, lastClientHeight, lastFooterHeight, @@ -200,11 +208,12 @@ export function Transcript({ const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; lastClientHeight.current = el.clientHeight; - const observer = new ResizeObserver(() => { - const previous = lastClientHeight.current ?? el.clientHeight; - lastClientHeight.current = el.clientHeight; + const observer = new ResizeObserver((entries) => { + const height = entries[0]?.contentRect.height ?? el.clientHeight; + const previous = lastClientHeight.current ?? height; + lastClientHeight.current = height; if (items.length === 0) return; - repinIfWasPinned(el.clientHeight - previous); + scheduleRepinIfWasPinned(height - previous); }); observer.observe(el); return () => { @@ -214,7 +223,7 @@ export function Transcript({ resizeFrame.current = null; } }; - }, [items.length]); + }, [items.length, scheduleRepinIfWasPinned]); // Footer height changes → smooth scroll repin with GSAP. useEffect(() => { @@ -223,8 +232,8 @@ export function Transcript({ const previous = lastFooterHeight.current ?? footerHeight; lastFooterHeight.current = footerHeight; if (items.length === 0) return; - repinIfWasPinned(previous - footerHeight); - }, [footerHeight, items.length]); + scheduleRepinIfWasPinned(previous - footerHeight); + }, [footerHeight, items.length, scheduleRepinIfWasPinned]); // After a non-fork rewind, scroll to the last user message (the // rewound-to point) so the user knows where they are. @@ -630,6 +639,16 @@ export function Transcript({ {empty && !hydrating && } + {hasOlderHistory && ( + + )} {turnGroups.length > HOT_TURNS && ( ; History(): Promise; HistoryForTab(tabID: string): Promise; + HistoryPage(beforeTurn: number, limit: number): Promise; + HistoryPageForTab(tabID: string, beforeTurn: number, limit: number): Promise; + HistoryCheckpointTurnsForTab(tabID: string): Promise; Checkpoints(): Promise; CheckpointsForTab(tabID: string): Promise; Rewind(turn: number, scope: string): Promise; @@ -162,7 +166,10 @@ export interface AppBindings { ListTrashedSessions(): Promise; ResumeSession(path: string): Promise; ResumeSessionForTab(tabID: string, path: string): Promise; + ResumeSessionPage(path: string, limit: number): Promise; + ResumeSessionPageForTab(tabID: string, path: string, limit: number): Promise; OpenChannelSessionForTab(tabID: string, path: string): Promise; + OpenChannelSessionPageForTab(tabID: string, path: string, limit: number): Promise; PreviewSession(path: string): Promise; DeleteSession(path: string): Promise; RestoreSession(path: string): Promise; @@ -1162,8 +1169,8 @@ function makeMockApp(): AppBindings { }); return out; }; - const mockTopicHistory = (topicId: string): HistoryMessage[] => { - switch (topicId) { + const mockTopicHistory = (topicId: string): HistoryMessage[] => { + switch (topicId) { case "topic_product": return [ { @@ -1237,9 +1244,22 @@ function makeMockApp(): AppBindings { ]; default: return []; - } - }; - const mockRuntimeInjected = new Set(); + } + }; + const mockHistoryPage = (messages: HistoryMessage[], beforeTurn = 0, limit = 60): HistoryPage => { + const totalTurns = messages.reduce((count, message) => count + (message.role === "user" ? 1 : 0), 0); + const safeLimit = Math.max(1, Math.min(200, Math.floor(limit || 60))); + const endTurn = beforeTurn > 0 && beforeTurn <= totalTurns ? beforeTurn : totalTurns; + const startTurn = Math.max(0, endTurn - safeLimit); + let turn = -1; + const pageMessages = messages.filter((message) => { + if (message.role === "user") turn += 1; + if (turn < 0) return startTurn === 0; + return turn >= startTurn && turn < endTurn; + }); + return { messages: pageMessages, startTurn, endTurn, totalTurns, hasOlder: startTurn > 0 }; + }; + const mockRuntimeInjected = new Set(); const queueMockTopicRuntime = (tab: TabMeta) => { if (!runningMock) return; const status = mockTopicStatus(tab.topicId); @@ -1814,6 +1834,20 @@ function makeMockApp(): AppBindings { } return this.History(); }, + async HistoryPage(beforeTurn = 0, limit = 60) { + return mockHistoryPage(await this.History(), beforeTurn, limit); + }, + async HistoryPageForTab(tabID: string, beforeTurn = 0, limit = 60) { + return mockHistoryPage(await this.HistoryForTab(tabID), beforeTurn, limit); + }, + async HistoryCheckpointTurnsForTab(tabID: string) { + const turns: number[] = []; + for (const message of await this.HistoryForTab(tabID)) { + if (message.role !== "user") continue; + turns.push(message.checkpointTurn ?? turns.length); + } + return turns; + }, async ListSessions() { return sessions.map((s) => ({ ...s })); }, @@ -1830,14 +1864,23 @@ function makeMockApp(): AppBindings { { role: "assistant", content: "This is a mock resumed transcript — the real one comes from the kernel." }, ]; }, - async ResumeSessionForTab(_tabID: string, path: string) { - return this.ResumeSession(path); - }, - async OpenChannelSessionForTab(tabID: string, path: string) { - mockTabs = mockTabs.map((tab) => tab.id === tabID ? { ...tab, sessionPath: path, readOnly: true } : tab); - return this.ResumeSession(path); - }, - async PreviewSession(path: string) { + async ResumeSessionForTab(_tabID: string, path: string) { + return this.ResumeSession(path); + }, + async ResumeSessionPage(path: string, limit = 60) { + return mockHistoryPage(await this.ResumeSession(path), 0, limit); + }, + async ResumeSessionPageForTab(_tabID: string, path: string, limit = 60) { + return this.ResumeSessionPage(path, limit); + }, + async OpenChannelSessionForTab(tabID: string, path: string) { + mockTabs = mockTabs.map((tab) => tab.id === tabID ? { ...tab, sessionPath: path, readOnly: true } : tab); + return this.ResumeSession(path); + }, + async OpenChannelSessionPageForTab(tabID: string, path: string, limit = 60) { + return mockHistoryPage(await this.OpenChannelSessionForTab(tabID, path), 0, limit); + }, + async PreviewSession(path: string) { const s = sessions.find((x) => x.path === path) ?? trashedSessions.find((x) => x.path === path); return [ { role: "user", content: s?.preview || `(mock) preview ${path}` }, diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index f0350cbd07..911c38e5bd 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -325,6 +325,14 @@ export interface HistoryToolCall { argumentsArchived?: boolean; } +export interface HistoryPage { + messages: HistoryMessage[]; + startTurn: number; + endTurn: number; + totalTurns: number; + hasOlder: boolean; +} + export interface PromptHistoryEntry { text: string; at: number; // unix ms @@ -412,6 +420,7 @@ export interface Meta { workspaceRoot?: string; workspaceName?: string; workspacePath?: string; + sessionPath?: string; gitBranch?: string; imageInputEnabled?: boolean; autoApproveTools?: boolean; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index c5affbabb8..71dd390f68 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -20,6 +20,7 @@ import type { ContextInfo, EffortInfo, HistoryMessage, + HistoryPage, JobView, MemoryCitation, MemoryView, @@ -43,6 +44,8 @@ export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversat export type MessageActionState = { turn: number; scope: MessageActionScope }; export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup"; +const HISTORY_PAGE_TURNS = 60; + export type Item = | { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number } | { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] } @@ -101,6 +104,10 @@ interface State { hydrateError?: string; hydrateHistoryLoaded?: boolean; hydratePlaceholderItems?: Item[]; + historyStartTurn: number; + historyTotalTurns: number; + historyHasOlder: boolean; + historyOlderLoading: boolean; backendActivationPending: boolean; messageAction?: MessageActionState; currentAssistant?: string; @@ -131,6 +138,10 @@ export const initialState: State = { jobs: [], checkpoints: [], hydrating: false, + historyStartTurn: 0, + historyTotalTurns: 0, + historyHasOlder: false, + historyOlderLoading: false, backendActivationPending: false, turnStartAt: 0, turnTokens: 0, @@ -186,6 +197,7 @@ export function metaFromTab(tab: TabMeta, existing?: Meta): Meta { workspaceRoot: tab.workspaceRoot || existing?.workspaceRoot || cwd, workspaceName: tab.workspaceName || existing?.workspaceName, workspacePath: tab.workspacePath || tab.workspaceRoot || existing?.workspacePath, + sessionPath: tab.sessionPath !== undefined ? tab.sessionPath : existing?.sessionPath, gitBranch: tab.gitBranch || existing?.gitBranch, autoApproveTools, bypass: autoApproveTools, @@ -213,6 +225,7 @@ export function sameMeta(a?: Meta, b?: Meta): boolean { a.workspaceRoot === b.workspaceRoot && a.workspaceName === b.workspaceName && a.workspacePath === b.workspacePath && + a.sessionPath === b.sessionPath && a.gitBranch === b.gitBranch && a.imageInputEnabled === b.imageInputEnabled && a.autoApproveTools === b.autoApproveTools && @@ -247,6 +260,13 @@ function hasCachedLiveTurn(state: State | undefined): boolean { ); } +function hasReusableCachedTranscript(state: State | undefined, sessionPath?: string): boolean { + if (!state || state.items.length === 0) return false; + const expectedSessionPath = (sessionPath ?? "").trim(); + if (!expectedSessionPath) return true; + return (state.meta?.sessionPath ?? "").trim() === expectedSessionPath; +} + /** Mirrors Go backend's ReadOnly() hints. */ export function isReadOnlyTool(name: string): boolean { switch (name) { @@ -322,7 +342,10 @@ type Action = | { type: "message_action_start"; action: MessageActionState } | { type: "message_action_done" } | { type: "history"; messages: HistoryMessage[] } - | { type: "history_checkpoint_turns"; messages: HistoryMessage[] } + | { type: "history_page"; page: HistoryPage; mode: "replace" | "prepend" } + | { type: "history_older_start" } + | { type: "history_older_error" } + | { type: "history_checkpoint_turns"; turns: number[] } | { type: "local_notice"; level: "info" | "warn"; text: string } | { type: "clearApproval" } | { type: "clearAsk" } @@ -445,24 +468,26 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str return { items, seq }; } -function mergeHistoryCheckpointTurns(items: Item[], messages: HistoryMessage[]): Item[] { - const turns = messages - .filter((m) => m.role === "user" && m.content.trim() !== "") - .map((m) => m.checkpointTurn); - if (!turns.some((turn) => turn != null)) return items; +function mergeHistoryCheckpointTurns(items: Item[], turns: number[], startTurn = 0): Item[] { + if (!turns.some((turn) => turn >= 0)) return items; + const offset = Math.max(0, Math.floor(startTurn)); let userIndex = 0; let changed = false; const next = items.map((item) => { if (item.kind !== "user") return item; - const turn = turns[userIndex]; + const turn = turns[offset + userIndex]; userIndex += 1; - if (turn == null || item.checkpointTurn === turn) return item; + if (turn == null || turn < 0 || item.checkpointTurn === turn) return item; changed = true; return { ...item, checkpointTurn: turn }; }); return changed ? next : items; } +function historyPageItems(page: HistoryPage): { items: Item[]; seq: number } { + return historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0); +} + function positionalToolResults(messages: HistoryMessage[]): Map { const out = new Map(); const consumed = new Set(); @@ -813,7 +838,10 @@ export function reducer(s: State, a: Action): State { }); return { ...s, items: finalized, running: false, turnActive: false, pendingPrompt, backgroundJobs, cancelRequested, cancellable, live: undefined, currentAssistant: undefined, approval: undefined, ask: undefined }; } - case "meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta }; + case "meta": { + const meta = a.meta.sessionPath === undefined && s.meta?.sessionPath !== undefined ? { ...a.meta, sessionPath: s.meta.sessionPath } : a.meta; + return sameMeta(s.meta, meta) ? s : { ...s, meta }; + } case "optimistic_meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta, hydrateError: undefined }; case "context": { const sessionTokens = typeof a.context.sessionTokens === "number" @@ -847,10 +875,27 @@ export function reducer(s: State, a: Action): State { case "message_action_done": return { ...s, messageAction: undefined }; case "history": { const { items, seq } = historyMessagesToItems(a.messages, "h", s.seq); - return { ...s, items: compactArchivedToolItems(items), seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined }; + return { ...s, items: compactArchivedToolItems(items), seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false }; } + case "history_page": { + const { items, seq } = historyPageItems(a.page); + const nextItems = a.mode === "prepend" ? [...items, ...s.items] : items; + return { + ...s, + items: compactArchivedToolItems(nextItems), + seq: Math.max(s.seq, seq), + hydrateHistoryLoaded: true, + hydratePlaceholderItems: undefined, + historyStartTurn: a.page.startTurn, + historyTotalTurns: a.page.totalTurns, + historyHasOlder: a.page.hasOlder, + historyOlderLoading: false, + }; + } + case "history_older_start": return s.historyOlderLoading ? s : { ...s, historyOlderLoading: true }; + case "history_older_error": return s.historyOlderLoading ? { ...s, historyOlderLoading: false } : s; case "history_checkpoint_turns": - return { ...s, items: mergeHistoryCheckpointTurns(s.items, a.messages) }; + return { ...s, items: mergeHistoryCheckpointTurns(s.items, a.turns, s.historyStartTurn) }; case "local_notice": return { ...s, running: false, turnActive: false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: a.level, text: a.text }] }; case "clearApproval": return { ...s, approval: undefined, pendingPrompt: false }; case "clearAsk": return { ...s, ask: undefined, pendingPrompt: false }; @@ -987,13 +1032,13 @@ export function useController() { tabId: string, reset = false, reason: HydrateReason = "startup", - options: { skipHistory?: boolean; placeholderItems?: Item[]; preserveCachedHistory?: boolean } = {}, + options: { skipHistory?: boolean; placeholderItems?: Item[]; preserveCachedHistory?: boolean; sessionPath?: string } = {}, ) => { const seq = bumpSessionLoadSeq(tabId); const hydrateStartedAt = Date.now(); const skipHistory = Boolean( options.skipHistory || - (options.preserveCachedHistory && !reset && (statesRef.current.get(tabId)?.items.length ?? 0) > 0), + (options.preserveCachedHistory && !reset && hasReusableCachedTranscript(statesRef.current.get(tabId), options.sessionPath)), ); addBreadcrumb("tab.hydrate", `start ${reason} ${tabId}`); dispatchTo(tabId, { type: "hydrate_start", reason, placeholderItems: options.placeholderItems }); @@ -1008,26 +1053,26 @@ export function useController() { // Phase 1: dispatch fast metadata immediately so the transcript can render // without waiting for slower ancillary calls (e.g. BalanceForTab network). - const [meta, history] = await Promise.all([ + const [meta, historyPage] = await Promise.all([ app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }), skipHistory ? Promise.resolve(undefined) - : app.HistoryForTab(tabId).catch((err) => { noteFailure("history", err); return undefined; }), + : app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }), ]); if (!stillCurrent()) return; if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta }); - if (!skipHistory && history !== undefined) { - const messages = asArray(history); - dispatchTo(tabId, { type: "history", messages }); + if (!skipHistory && historyPage !== undefined) { + const messages = asArray(historyPage.messages); + dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" }); addBreadcrumb( "tab.hydrate", - `history done ${tabId} count=${messages.length} ms=${Date.now() - historyStartedAt}`, + `history page ${tabId} messages=${messages.length} turns=${historyPage.startTurn}-${historyPage.endTurn}/${historyPage.totalTurns} ms=${Date.now() - historyStartedAt}`, ); if (reason === "switch-tab") { addBreadcrumb( "tab.switch", - `history-done ${tabId} count=${messages.length} ms=${Date.now() - historyStartedAt}`, + `history-done ${tabId} messages=${messages.length} turns=${historyPage.startTurn}-${historyPage.endTurn}/${historyPage.totalTurns} ms=${Date.now() - historyStartedAt}`, ); } } else if (skipHistory) { @@ -1038,26 +1083,57 @@ export function useController() { } } - // Phase 2: ancillary data (checkpoints, context, balance, effort, jobs). + // Phase 2: local ancillary data. Balance is a network call and is refreshed + // after hydrate_done so it cannot hold the transcript loading state open. // These don't block the transcript, so they're batched into one render. - const [effort, jobs, checkpoints, context, balance] = await Promise.all([ + const [effort, jobs, checkpoints, context] = await Promise.all([ app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }), app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }), app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }), app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }), - app.BalanceForTab(tabId).catch((err) => { noteFailure("balance", err); return undefined; }), ]); if (!stillCurrent()) return; if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort }); if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) }); if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) }); if (context !== undefined) dispatchTo(tabId, { type: "context", context }); - if (balance !== undefined) dispatchTo(tabId, { type: "balance", balance }); dispatchTo(tabId, { type: "hydrate_done" }); addBreadcrumb("tab.hydrate", `done ${reason} ${tabId} ms=${Date.now() - hydrateStartedAt}`); + app.BalanceForTab(tabId) + .then((balance) => { + if (sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "balance", balance }); + }) + .catch((err) => { noteFailure("balance", err); }); }, [bumpSessionLoadSeq, dispatchTo, sessionLoadCurrent]); + const loadOlderHistory = useCallback(async (tabId?: string): Promise => { + const targetTabId = tabId || activeTabIdRef.current; + if (!targetTabId) return; + const state = statesRef.current.get(targetTabId); + if (!state?.historyHasOlder || state.historyOlderLoading || state.running) return; + const beforeTurn = state.historyStartTurn; + const sessionPath = state.meta?.sessionPath ?? ""; + dispatchTo(targetTabId, { type: "history_older_start" }); + const startedAt = Date.now(); + try { + const page = await app.HistoryPageForTab(targetTabId, beforeTurn, HISTORY_PAGE_TURNS); + const current = statesRef.current.get(targetTabId); + if (!current || current.historyStartTurn !== beforeTurn || (current.meta?.sessionPath ?? "") !== sessionPath) { + dispatchTo(targetTabId, { type: "history_older_error" }); + return; + } + dispatchTo(targetTabId, { type: "history_page", page, mode: "prepend" }); + addBreadcrumb( + "tab.hydrate", + `history older ${targetTabId} messages=${asArray(page.messages).length} turns=${page.startTurn}-${page.endTurn}/${page.totalTurns} ms=${Date.now() - startedAt}`, + ); + } catch (err) { + dispatchTo(targetTabId, { type: "history_older_error" }); + addBreadcrumb("tab.hydrate", `history older failed ${targetTabId}: ${errorMessage(err)}`); + } + }, [dispatchTo]); + const activeTabFromBackend = useCallback(async (): Promise => { const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[])); return tabs.find((tab) => tab.active) ?? tabs[0]; @@ -1178,8 +1254,8 @@ export function useController() { } if (e.kind === "turn_done") { if (!e.err) { - app.HistoryForTab(targetTabId) - .then((messages) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", messages: asArray(messages) })) + app.HistoryCheckpointTurnsForTab(targetTabId) + .then((turns) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", turns: asArray(turns) })) .catch(() => {}); } void refreshMetaForTab(targetTabId, dispatchTo); @@ -1448,9 +1524,11 @@ export function useController() { else if (!(await waitForBackendActiveTab(targetTabId))) return; const seq = bumpSessionLoadSeq(targetTabId); dispatchTo(targetTabId, { type: "hydrate_start", reason: "resume-session" }); - let messages: HistoryMessage[]; + let page: HistoryPage; try { - messages = asArray(await (tabId ? app.ResumeSessionForTab(tabId, path) : app.ResumeSession(path))); + page = tabId + ? await app.ResumeSessionPageForTab(tabId, path, HISTORY_PAGE_TURNS) + : await app.ResumeSessionPage(path, HISTORY_PAGE_TURNS); } catch (err) { if (sessionLoadCurrent(targetTabId, seq)) { dispatchTo(targetTabId, { type: "hydrate_error", reason: "resume-session", error: errorMessage(err) }); @@ -1460,7 +1538,7 @@ export function useController() { } if (!sessionLoadCurrent(targetTabId, seq)) return; dispatchTo(targetTabId, { type: "reset" }); - dispatchTo(targetTabId, { type: "history", messages }); + dispatchTo(targetTabId, { type: "history_page", page, mode: "replace" }); dispatchTo(targetTabId, { type: "hydrate_done" }); app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {}); void refreshCheckpoints(targetTabId); @@ -1471,9 +1549,9 @@ export function useController() { await waitForTabReady(tabId); const seq = bumpSessionLoadSeq(tabId); dispatchTo(tabId, { type: "hydrate_start", reason: "resume-session" }); - let messages: HistoryMessage[]; + let page: HistoryPage; try { - messages = asArray(await app.OpenChannelSessionForTab(tabId, path)); + page = await app.OpenChannelSessionPageForTab(tabId, path, HISTORY_PAGE_TURNS); } catch (err) { if (sessionLoadCurrent(tabId, seq)) { dispatchTo(tabId, { type: "hydrate_error", reason: "resume-session", error: errorMessage(err) }); @@ -1483,7 +1561,7 @@ export function useController() { } if (!sessionLoadCurrent(tabId, seq)) return; dispatchTo(tabId, { type: "reset" }); - dispatchTo(tabId, { type: "history", messages }); + dispatchTo(tabId, { type: "history_page", page, mode: "replace" }); dispatchTo(tabId, { type: "hydrate_done" }); app.ContextUsageForTab(tabId).then((context) => dispatchTo(tabId, { type: "context", context })).catch(() => {}); void refreshCheckpoints(tabId); @@ -1618,6 +1696,8 @@ export function useController() { // Tab management: switch preserves per-tab state; open creates it. const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise => { const startedAt = Date.now(); + const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath; + const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath); addBreadcrumb("tab.switch", `click ${tabId}`); setActiveTabId(tabId); activeTabIdRef.current = tabId; @@ -1645,6 +1725,8 @@ export function useController() { const tabs = await reconcileTabRuntime(tabId, { hydrateSessionData: false }); void loadSessionDataForTab(tabId, false, "switch-tab", { skipHistory: hasCachedLiveTurn(statesRef.current.get(tabId)), + preserveCachedHistory, + sessionPath: targetSessionPath, }); return tabs; }) @@ -1658,13 +1740,17 @@ export function useController() { const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string): Promise => { const meta = await app.OpenProjectTab(workspaceRoot, topicId); const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined; - const isNewTab = !statesRef.current.has(meta.id); + const prevState = statesRef.current.get(meta.id); + const isNewTab = !prevState; + const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath); setActiveTabId(meta.id); activeTabIdRef.current = meta.id; confirmBackendActiveTab(meta.id); dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) }); void loadSessionDataForTab(meta.id, isNewTab, "open-topic", { placeholderItems: isNewTab ? prevItems : undefined, + preserveCachedHistory, + sessionPath: meta.sessionPath, }); return meta; }, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]); @@ -1672,13 +1758,17 @@ export function useController() { const openGlobalTab = useCallback(async (topicId: string): Promise => { const meta = await app.OpenGlobalTab(topicId); const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined; - const isNewTab = !statesRef.current.has(meta.id); + const prevState = statesRef.current.get(meta.id); + const isNewTab = !prevState; + const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath); setActiveTabId(meta.id); activeTabIdRef.current = meta.id; confirmBackendActiveTab(meta.id); dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) }); void loadSessionDataForTab(meta.id, isNewTab, "open-topic", { placeholderItems: isNewTab ? prevItems : undefined, + preserveCachedHistory, + sessionPath: meta.sessionPath, }); return meta; }, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]); @@ -1686,13 +1776,17 @@ export function useController() { const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise => { const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath); const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined; - const isNewTab = !statesRef.current.has(meta.id); + const prevState = statesRef.current.get(meta.id); + const isNewTab = !prevState; + const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath); setActiveTabId(meta.id); activeTabIdRef.current = meta.id; confirmBackendActiveTab(meta.id); dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) }); void loadSessionDataForTab(meta.id, isNewTab, "open-topic", { placeholderItems: isNewTab ? prevItems : undefined, + preserveCachedHistory, + sessionPath: meta.sessionPath, }); return meta; }, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]); @@ -1758,6 +1852,7 @@ export function useController() { activeTabId, send, sendToTab, runShell, steer, notice, cancel, approve, answerQuestion, setControllerMode, setCollaborationMode, setToolApprovalMode, setGoal, clearGoal, newSession, clearSession, listSessions, listTrashedSessions, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession, + loadOlderHistory, refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, setModel, setEffort, setTokenMode, fetchMemory, remember, forget, saveDoc, switchTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, closeTab, reorderTabs, diff --git a/desktop/frontend/src/lib/useScrollManager.ts b/desktop/frontend/src/lib/useScrollManager.ts index ba122a202a..95641c6a48 100644 --- a/desktop/frontend/src/lib/useScrollManager.ts +++ b/desktop/frontend/src/lib/useScrollManager.ts @@ -22,6 +22,8 @@ export function useScrollManager() { const gsapCtx = useRef(null); const prevQuestionsLen = useRef(0); const resizeFrame = useRef(null); + const repinFrame = useRef(null); + const pendingRepinHeightDelta = useRef(0); const layoutScrollFrames = useRef([]); const lastClientHeight = useRef(null); const lastFooterHeight = useRef(null); @@ -32,6 +34,7 @@ export function useScrollManager() { return () => { gsapCtx.current?.revert(); if (resizeFrame.current !== null) cancelAnimationFrame(resizeFrame.current); + if (repinFrame.current !== null) cancelAnimationFrame(repinFrame.current); for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame); layoutScrollFrames.current = []; }; @@ -158,6 +161,20 @@ export function useScrollManager() { [scrollToBottom], ); + const scheduleRepinIfWasPinned = useCallback( + (containerHeightDelta: number) => { + pendingRepinHeightDelta.current += containerHeightDelta; + if (repinFrame.current !== null) return; + repinFrame.current = requestAnimationFrame(() => { + repinFrame.current = null; + const delta = pendingRepinHeightDelta.current; + pendingRepinHeightDelta.current = 0; + repinIfWasPinned(delta); + }); + }, + [repinIfWasPinned], + ); + /** * Track question count changes to call onNewQuestion. * Returns the previous length ref for useEffect comparison. @@ -182,6 +199,7 @@ export function useScrollManager() { scrollToBottomAfterLayout, onNewQuestion, repinIfWasPinned, + scheduleRepinIfWasPinned, trackQuestions, resizeFrame, lastClientHeight, diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 41555a58f8..ef7d4e0610 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -3167,6 +3167,10 @@ a[href] { border-color: var(--accent-soft); background: var(--bg-elev); } +.warm-collapse:disabled { + cursor: wait; + opacity: 0.65; +} .warm-turn { max-width: var(--maxw); margin: 6px auto; diff --git a/desktop/history_test.go b/desktop/history_test.go index a3be5c9ba5..70bc3df40d 100644 --- a/desktop/history_test.go +++ b/desktop/history_test.go @@ -120,6 +120,112 @@ func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testin } } +func TestHistoryPageFromMessagesWindowsByUserTurn(t *testing.T) { + messages := []HistoryMessage{ + {Role: "notice", Content: "session restored"}, + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "one"}, + {Role: "tool", ToolName: "bash", Content: "tool one"}, + {Role: "user", Content: "second"}, + {Role: "assistant", Content: "two"}, + {Role: "user", Content: "third"}, + {Role: "assistant", Content: "three"}, + } + + latest := historyPageFromMessages(messages, 0, 2) + if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder { + t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest) + } + if len(latest.Messages) != 4 || latest.Messages[0].Content != "second" || latest.Messages[3].Content != "three" { + t.Fatalf("latest page messages = %+v, want second and third turns", latest.Messages) + } + + older := historyPageFromMessages(messages, latest.StartTurn, 2) + if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder { + t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older) + } + if len(older.Messages) != 4 || older.Messages[0].Content != "session restored" || older.Messages[1].Content != "first" { + t.Fatalf("older page messages = %+v, want prelude and first turn", older.Messages) + } +} + +func TestHistoryPageFromProviderMessagesWindowsVisibleUsers(t *testing.T) { + msgs := []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "first"}, + {Role: provider.RoleAssistant, Content: "one"}, + {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."}, + {Role: provider.RoleAssistant, Content: "hidden continuation"}, + {Role: provider.RoleUser, Content: "second"}, + {Role: provider.RoleAssistant, Content: "two"}, + {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\nupdate the plan"}, + {Role: provider.RoleUser, Content: "third"}, + {Role: provider.RoleAssistant, Content: "three"}, + } + + latest := historyPageFromProviderMessages( + msgs, + func(content string) string { return content }, + nil, + map[int]int{1: 0, 5: 2, 8: 3}, + 0, + 2, + ) + if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder { + t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest) + } + if len(latest.Messages) != 5 { + t.Fatalf("latest page length = %d, want 5: %+v", len(latest.Messages), latest.Messages) + } + if latest.Messages[0].Role != "user" || latest.Messages[0].Content != "second" { + t.Fatalf("first latest message = %+v, want second user", latest.Messages[0]) + } + if latest.Messages[0].CheckpointTurn == nil || *latest.Messages[0].CheckpointTurn != 2 { + t.Fatalf("second user checkpoint = %v, want 2", latest.Messages[0].CheckpointTurn) + } + if latest.Messages[2].Role != "notice" || !strings.Contains(latest.Messages[2].Content, "update the plan") { + t.Fatalf("steer message = %+v, want notice in second turn window", latest.Messages[2]) + } + if latest.Messages[3].Role != "user" || latest.Messages[3].Content != "third" { + t.Fatalf("third latest message = %+v, want third user", latest.Messages[3]) + } + if latest.Messages[3].CheckpointTurn == nil || *latest.Messages[3].CheckpointTurn != 3 { + t.Fatalf("third user checkpoint = %v, want 3", latest.Messages[3].CheckpointTurn) + } + + older := historyPageFromProviderMessages( + msgs, + func(content string) string { return content }, + nil, + map[int]int{1: 0, 5: 2, 8: 3}, + latest.StartTurn, + 2, + ) + if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder { + t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older) + } + if len(older.Messages) != 4 || older.Messages[0].Role != "system" || older.Messages[1].Content != "first" || older.Messages[3].Content != "hidden continuation" { + t.Fatalf("older page messages = %+v, want prelude and first visible turn", older.Messages) + } +} + +func TestHistoryCheckpointTurnsSkipsHiddenUsers(t *testing.T) { + msgs := []provider.Message{ + {Role: provider.RoleUser, Content: "first visible"}, + {Role: provider.RoleAssistant, Content: "ok"}, + {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."}, + {Role: provider.RoleUser, Content: "second visible"}, + } + got := historyCheckpointTurns( + msgs, + func(content string) string { return content }, + map[int]int{0: 0, 2: 1, 3: 2}, + ) + if len(got) != 2 || got[0] != 0 || got[1] != 2 { + t.Fatalf("checkpoint turns = %v, want [0 2]", got) + } +} + func TestHistoryForTabRestoresPlannerDisplayAfterReload(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") diff --git a/internal/agent/branch.go b/internal/agent/branch.go index 8b03561936..520b36946a 100644 --- a/internal/agent/branch.go +++ b/internal/agent/branch.go @@ -44,8 +44,9 @@ type BranchMeta struct { // in-memory conversation, so ListSessions stays O(1) per session instead of // O(file size). Gated by SchemaVersion (above), not Turns == 0, so a // genuinely-empty session is recorded once and never re-decoded. - Turns int `json:"turns,omitempty"` - Preview string `json:"preview,omitempty"` + Turns int `json:"turns,omitempty"` + Preview string `json:"preview,omitempty"` + InFlightTurn *InFlightTurnMeta `json:"in_flight_turn,omitempty"` } // BranchMetaCountsVersion is stamped into BranchMeta.SchemaVersion whenever a @@ -54,6 +55,15 @@ type BranchMeta struct { // existing listings re-derive them instead of trusting a stale cache. const BranchMetaCountsVersion = 1 +// InFlightTurnMeta records the message-log boundary for a foreground turn that +// has started but not yet reached TurnDone. If the process exits mid-turn, a +// later resume can strip the partial assistant/tool tail without guessing. +type InFlightTurnMeta struct { + StartMessageIndex int `json:"start_message_index"` + PreserveUser bool `json:"preserve_user"` + StartedAt time.Time `json:"started_at"` +} + func (m BranchMeta) DefaultScope() string { switch m.Scope { case "project": @@ -190,6 +200,34 @@ func TouchBranchMeta(sessionPath string) error { return saveBranchMeta(sessionPath, m, false) } +func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error { + if startMessageIndex < 0 { + startMessageIndex = 0 + } + m, err := EnsureBranchMeta(sessionPath) + if err != nil { + return err + } + m.InFlightTurn = &InFlightTurnMeta{ + StartMessageIndex: startMessageIndex, + PreserveUser: preserveUser, + StartedAt: time.Now().UTC(), + } + return SaveBranchMetaPreserveUpdated(sessionPath, m) +} + +func ClearSessionInFlightTurn(sessionPath string) error { + m, ok, err := LoadBranchMeta(sessionPath) + if err != nil || !ok { + return err + } + if m.InFlightTurn == nil { + return nil + } + m.InFlightTurn = nil + return SaveBranchMetaPreserveUpdated(sessionPath, m) +} + func ListBranches(dir string) ([]BranchInfo, error) { entries, err := os.ReadDir(dir) if err != nil { diff --git a/internal/agent/branch_test.go b/internal/agent/branch_test.go index cfecd3b6f6..a493cc3d7d 100644 --- a/internal/agent/branch_test.go +++ b/internal/agent/branch_test.go @@ -3,6 +3,7 @@ package agent import ( "path/filepath" "testing" + "time" "reasonix/internal/provider" ) @@ -92,6 +93,73 @@ func TestListBranchesSkipsCleanupPending(t *testing.T) { } } +func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "in-flight.jsonl") + sess := NewSession("sys") + sess.Add(provider.Message{Role: provider.RoleUser, Content: "work"}) + if err := sess.Save(path); err != nil { + t.Fatal(err) + } + if err := TouchBranchMeta(path); err != nil { + t.Fatal(err) + } + before, ok, err := LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err) + } + updatedAt := before.UpdatedAt + + if err := MarkSessionInFlightTurn(path, 1, true); err != nil { + t.Fatal(err) + } + marked, ok, err := LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta marked ok=%v err=%v", ok, err) + } + if marked.InFlightTurn == nil { + t.Fatal("in-flight turn marker missing") + } + if marked.InFlightTurn.StartMessageIndex != 1 || !marked.InFlightTurn.PreserveUser { + t.Fatalf("in-flight marker = %+v, want index=1 preserveUser=true", marked.InFlightTurn) + } + if marked.InFlightTurn.StartedAt.IsZero() || time.Since(marked.InFlightTurn.StartedAt) > time.Minute { + t.Fatalf("unexpected marker timestamp: %v", marked.InFlightTurn.StartedAt) + } + if !marked.UpdatedAt.Equal(updatedAt) { + t.Fatalf("MarkSessionInFlightTurn updated activity time: got %v want %v", marked.UpdatedAt, updatedAt) + } + + if err := UpdateSessionMeta(path, "model-a", "preview", 1, true); err != nil { + t.Fatal(err) + } + refreshed, ok, err := LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta refreshed ok=%v err=%v", ok, err) + } + if refreshed.InFlightTurn == nil { + t.Fatal("UpdateSessionMeta dropped in-flight marker") + } + if refreshed.InFlightTurn.StartMessageIndex != 1 || !refreshed.InFlightTurn.PreserveUser { + t.Fatalf("refreshed in-flight marker = %+v, want index=1 preserveUser=true", refreshed.InFlightTurn) + } + updatedAt = refreshed.UpdatedAt + + if err := ClearSessionInFlightTurn(path); err != nil { + t.Fatal(err) + } + cleared, ok, err := LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta cleared ok=%v err=%v", ok, err) + } + if cleared.InFlightTurn != nil { + t.Fatalf("in-flight marker survived clear: %+v", cleared.InFlightTurn) + } + if !cleared.UpdatedAt.Equal(updatedAt) { + t.Fatalf("ClearSessionInFlightTurn updated activity time: got %v want %v", cleared.UpdatedAt, updatedAt) + } +} + func TestSessionModelRoundTripPreservesActivity(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") diff --git a/internal/control/controller.go b/internal/control/controller.go index 74d794897a..521db8039b 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -1081,6 +1081,13 @@ func (c *Controller) RunShell(command string) { durationMs := time.Since(start).Milliseconds() out := buf.String() + if ctx.Err() == context.Canceled { + c.sink.Emit(event.Event{ + Kind: event.ToolResult, + Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: i18n.M.TurnCancelled, DurationMs: durationMs}, + }) + return nil + } if ctx.Err() == context.DeadlineExceeded { c.sink.Emit(event.Event{ Kind: event.ToolResult, @@ -1165,6 +1172,8 @@ func (c *Controller) Run(ctx context.Context, input string) error { } defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), c.turn) }() } + c.markInFlightTurn(startMessages, true) + defer c.clearInFlightTurn() return c.runner.Run(ctx, input) } @@ -1180,6 +1189,10 @@ func (c *Controller) Cancel() { if cancel != nil { c.approval.clearAll() cancel() + return + } + if c.goals.active() { + c.stopGoal(GoalStatusStopped) } } @@ -2002,6 +2015,7 @@ func (c *Controller) Resume(s *agent.Session, path string) { c.rebindCheckpoints(path) c.restoreTerminalGoalTodos(path) c.loadGuardianSession() + c.recoverInterruptedTurn(path) c.maybeColdResumePrune(path) } @@ -2161,12 +2175,58 @@ func (c *Controller) messageCount() int { return len(c.executor.Session().Snapshot()) } +func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) { + path := c.SessionPath() + if path == "" { + return + } + if err := agent.MarkSessionInFlightTurn(path, startMessageIndex, preserveUser); err != nil { + slog.Warn("controller: mark in-flight turn", "err", err) + } +} + +func (c *Controller) clearInFlightTurn() { + path := c.SessionPath() + if path == "" { + return + } + if err := agent.ClearSessionInFlightTurn(path); err != nil { + slog.Warn("controller: clear in-flight turn", "err", err) + } +} + +func (c *Controller) recoverInterruptedTurn(path string) { + if c.executor == nil || path == "" { + return + } + meta, ok, err := agent.LoadBranchMeta(path) + if err != nil || !ok || meta.InFlightTurn == nil { + if err != nil { + slog.Warn("controller: load in-flight turn marker", "err", err) + } + return + } + marker := meta.InFlightTurn + msgs := c.executor.Session().Snapshot() + changed := marker.StartMessageIndex >= 0 && len(msgs) > marker.StartMessageIndex + if changed { + if marker.PreserveUser { + c.stripCancelledVisibleTurnMessagesAfter(marker.StartMessageIndex) + } else { + c.stripTurnMessagesAfter(marker.StartMessageIndex) + } + if err := c.snapshot(false); err != nil { + slog.Warn("controller: post-interrupted-turn snapshot", "err", err) + } + } + if err := agent.ClearSessionInFlightTurn(path); err != nil { + slog.Warn("controller: clear stale in-flight turn", "err", err) + } +} + // stripTurnMessagesAfter truncates the executor's session to keep only messages -// before the given index, discarding an incomplete turn (the user prompt plus -// every assistant / tool message that followed). It is called when the user -// explicitly cancels a turn so the next prompt starts clean — the model won't -// see leftover in-progress todo items or partial tool calls and re-execute -// interrupted work. +// before the given index, discarding an incomplete synthetic turn (the synthetic +// user prompt plus every assistant/tool message that followed). func (c *Controller) stripTurnMessagesAfter(idx int) { if c.executor == nil { return @@ -2175,7 +2235,38 @@ func (c *Controller) stripTurnMessagesAfter(idx int) { if len(msgs) <= idx { return } - c.executor.Session().Replace(msgs[:idx]) + c.replaceSessionAfterCancel(msgs[:idx]) +} + +// stripCancelledVisibleTurnMessagesAfter removes assistant/tool remnants from a +// cancelled visible turn while preserving the real user prompt that started it. +func (c *Controller) stripCancelledVisibleTurnMessagesAfter(idx int) { + if c.executor == nil { + return + } + msgs := c.executor.Session().Snapshot() + if len(msgs) <= idx { + return + } + next := append([]provider.Message{}, msgs[:idx]...) + for _, m := range msgs[idx:] { + if m.Role != provider.RoleUser { + continue + } + if IsSyntheticUserMessage(m.Content) { + continue + } + if _, ok := agent.SteerText(m.Content); ok { + continue + } + next = append(next, m) + break + } + c.replaceSessionAfterCancel(next) +} + +func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) { + c.executor.Session().Replace(append([]provider.Message(nil), msgs...)) // Rebuild canonical todo state from the truncated transcript so // Controller.Todos(), goal readiness, and the task panel no longer see // the in_progress items written by the cancelled turn. diff --git a/internal/control/goal_test.go b/internal/control/goal_test.go index 47a6337f69..8b548d5196 100644 --- a/internal/control/goal_test.go +++ b/internal/control/goal_test.go @@ -209,6 +209,25 @@ func TestPlainInputWithWeakResearchSignalDoesNotAutoStartGoal(t *testing.T) { } } +func TestCancelStopsIdleGoalWithIncompleteTodos(t *testing.T) { + ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard) + ag.SeedTodoState([]evidence.TodoItem{{Content: "finish the migration", Status: "in_progress"}}) + c := New(Options{Executor: ag, Sink: event.Discard}) + c.SetGoalWithResearchMode("finish the migration", GoalResearchOn) + + c.Cancel() + + if got := c.GoalStatus(); got != GoalStatusStopped { + t.Fatalf("GoalStatus() = %q, want stopped", got) + } + if got := c.Goal(); got != "finish the migration" { + t.Fatalf("Goal() = %q, want stopped goal text to remain for display/persistence", got) + } + if todos := c.Todos(); len(todos) != 1 || todos[0].Status != "in_progress" { + t.Fatalf("Todos() after stopping idle goal = %+v, want incomplete todo retained", todos) + } +} + func TestGoalRepeatedBlockedStopsAfterThreeTurns(t *testing.T) { prov := &scriptedTurns{turns: [][]provider.Chunk{ textTurn("Blocked.\n\n[goal:blocked: Needs credentials.]"), diff --git a/internal/control/shell_test.go b/internal/control/shell_test.go index 3766abdc66..47466020b6 100644 --- a/internal/control/shell_test.go +++ b/internal/control/shell_test.go @@ -6,6 +6,7 @@ import ( "time" "reasonix/internal/event" + "reasonix/internal/i18n" "reasonix/internal/sandbox" ) @@ -146,7 +147,7 @@ func TestRunShell_FailingCommand(t *testing.T) { } func TestRunShell_CancelStopsCommand(t *testing.T) { - sink, done, _ := collectSink() + sink, done, events := collectSink() ctrl := &Controller{sink: sink} command := "sleep 30" @@ -166,4 +167,20 @@ func TestRunShell_CancelStopsCommand(t *testing.T) { if e.Kind != event.TurnDone { t.Fatalf("done event kind = %v, want TurnDone", e.Kind) } + if e.Err != nil { + t.Fatalf("cancelled shell TurnDone err = %v, want nil", e.Err) + } + var result *event.Event + for i := range *events { + if (*events)[i].Kind == event.ToolResult { + result = &(*events)[i] + break + } + } + if result == nil { + t.Fatal("expected ToolResult for cancelled shell") + } + if result.Tool.Err != i18n.M.TurnCancelled { + t.Fatalf("cancelled shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled) + } } diff --git a/internal/control/turn_orchestrator.go b/internal/control/turn_orchestrator.go index 082c726497..654a0272d7 100644 --- a/internal/control/turn_orchestrator.go +++ b/internal/control/turn_orchestrator.go @@ -88,17 +88,25 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest } defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }() } - if err := c.runner.Run(ctx, input); err != nil { + c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw)) + err := c.runner.Run(ctx, input) + if err == nil { + c.clearInFlightTurn() + } else { // When the user explicitly cancels (Ctrl+C), the incomplete turn's // assistant messages and tool results are already saved to the - // session. If they stay, the next turn's model sees leftover + // session. If they stay, the next turn's model sees leftover // in-progress todo items and partial tool calls and may re-execute - // the interrupted work (the next user message looks like a - // continuation instead of a fresh task). Strip the turn so the - // next prompt starts clean. + // the interrupted work. Keep the real user prompt for visible turns so + // follow-up questions and resumes do not lose the user's context (#5499). if errors.Is(err, context.Canceled) && c.CancelRequested() { - c.stripTurnMessagesAfter(startMessages) + if turn.synthetic || IsSyntheticUserMessage(turn.raw) { + c.stripTurnMessagesAfter(startMessages) + } else { + c.stripCancelledVisibleTurnMessagesAfter(startMessages) + } } + c.clearInFlightTurn() return err } c.mu.Lock() @@ -128,7 +136,12 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest // later turn (even "continue") falls back to the normal per-tool approval. c.approval.setPlanAutoApprove(true) defer c.approval.setPlanAutoApprove(false) - if err := o.runComposedSyntheticTurn(ctx, planApprovedMessage); err != nil { + err = func() error { + c.markInFlightTurn(execStart, false) + defer c.clearInFlightTurn() + return o.runComposedSyntheticTurn(ctx, planApprovedMessage) + }() + if err != nil { if errors.Is(err, context.Canceled) && c.CancelRequested() { c.stripTurnMessagesAfter(execStart) } diff --git a/internal/control/turn_orchestrator_test.go b/internal/control/turn_orchestrator_test.go index 404e38041f..7befb1f32a 100644 --- a/internal/control/turn_orchestrator_test.go +++ b/internal/control/turn_orchestrator_test.go @@ -413,12 +413,12 @@ func TestTurnOrchestratorStopHookCancelledContext(t *testing.T) { } } -// TestTurnOrchestratorCancelStripsIncompleteTurn verifies that when the user -// explicitly cancels a turn (Ctrl+C), the incomplete turn's messages are -// stripped from the session so the next turn starts clean. Without this, the -// model sees leftover in-progress todo items and partial tool calls and may -// re-execute the interrupted work. See #5286. -func TestTurnOrchestratorCancelStripsIncompleteTurn(t *testing.T) { +// TestTurnOrchestratorCancelPreservesVisibleUserPrompt verifies that when the +// user explicitly cancels a visible turn (Ctrl+C), the real user prompt remains +// in the session while incomplete assistant/tool remnants are stripped. Without +// this, the next user message can lose the just-submitted context (#5499); if +// the remnants remain, the model can re-execute interrupted work (#5286). +func TestTurnOrchestratorCancelPreservesVisibleUserPrompt(t *testing.T) { sess := agent.NewSession("you are a helpful agent") // Pre-populate with a few messages from an earlier turn. sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"}) @@ -457,11 +457,20 @@ func TestTurnOrchestratorCancelStripsIncompleteTurn(t *testing.T) { t.Fatalf("expected context.Canceled, got %v", err) } - // The incomplete turn (user prompt + assistant + tool result) must be - // stripped; the session must only contain the pre-turn messages. + // The visible user prompt must stay, while assistant/tool remnants from the + // cancelled turn must be stripped. msgs := sess.Messages - if len(msgs) != preCount { - t.Fatalf("session messages after cancel = %d, want pre-turn count %d: %+v", len(msgs), preCount, msgs) + if len(msgs) != preCount+1 { + t.Fatalf("session messages after cancel = %d, want pre-turn + user prompt %d: %+v", len(msgs), preCount+1, msgs) + } + last := msgs[len(msgs)-1] + if last.Role != provider.RoleUser || last.Content != "add config file abc" { + t.Fatalf("last message after cancel = %+v, want preserved visible user prompt", last) + } + for _, m := range msgs[preCount+1:] { + if m.Role == provider.RoleAssistant || m.Role == provider.RoleTool { + t.Fatalf("cancelled turn remnant survived: %+v", m) + } } // todoState must also be reset: the in_progress todo written by the @@ -487,6 +496,7 @@ func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk(t *testing.T) { wantNonSystem++ } } + wantNonSystem++ // the cancelled visible turn's user prompt is preserved runner := &cancelStrippingRunner{ session: sess, @@ -514,21 +524,121 @@ func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk(t *testing.T) { t.Fatalf("expected context.Canceled, got %v", err) } - // Load the session file written after the strip and verify it contains only - // the pre-cancel messages — not the partial assistant/tool messages. + // Load the session file written after the strip and verify it contains the + // pre-cancel messages plus the visible user prompt — not the partial + // assistant/tool messages. loaded, err := agent.LoadSession(sessionPath) if err != nil { t.Fatalf("LoadSession: %v", err) } nonSystem := 0 + var last provider.Message for _, m := range loaded.Messages { if m.Role != provider.RoleSystem { nonSystem++ + last = m } } if nonSystem != wantNonSystem { t.Fatalf("on-disk message count (non-system) = %d, want %d — stale partial turn still on disk", nonSystem, wantNonSystem) } + if last.Role != provider.RoleUser || last.Content != "do something" { + t.Fatalf("last on-disk message = %+v, want preserved visible user prompt", last) + } +} + +func TestResumeClearsStaleVisibleInFlightTurn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stale-visible.jsonl") + sess := agent.NewSession("system") + sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"}) + start := len(sess.Messages) + sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue work"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "working", ToolCalls: []provider.ToolCall{ + {ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"continue work","status":"in_progress"}]}`}, + }}) + sess.Add(provider.Message{Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "todo-1", Name: "todo_write"}) + if err := sess.Save(path); err != nil { + t.Fatal(err) + } + if err := agent.MarkSessionInFlightTurn(path, start, true); err != nil { + t.Fatal(err) + } + + loaded, err := agent.LoadSession(path) + if err != nil { + t.Fatal(err) + } + exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) + c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path}) + c.Resume(loaded, path) + + msgs := exec.Session().Snapshot() + if len(msgs) != start+1 { + t.Fatalf("resumed messages = %d, want pre-turn + user prompt %d: %+v", len(msgs), start+1, msgs) + } + last := msgs[len(msgs)-1] + if last.Role != provider.RoleUser || last.Content != "continue work" { + t.Fatalf("last resumed message = %+v, want preserved visible user prompt", last) + } + if todos := c.Todos(); len(todos) != 0 { + t.Fatalf("Todos() after stale in-flight recovery = %+v, want empty", todos) + } + reloaded, err := agent.LoadSession(path) + if err != nil { + t.Fatal(err) + } + if len(reloaded.Messages) != start+1 { + t.Fatalf("persisted messages = %d, want cleaned count %d: %+v", len(reloaded.Messages), start+1, reloaded.Messages) + } + meta, ok, err := agent.LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err) + } + if meta.InFlightTurn != nil { + t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn) + } +} + +func TestResumeClearsStaleSyntheticInFlightTurn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stale-synthetic.jsonl") + sess := agent.NewSession("system") + sess.Add(provider.Message{Role: provider.RoleUser, Content: "ship it"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Started.\n\n[goal:continue]"}) + start := len(sess.Messages) + sess.Add(provider.Message{Role: provider.RoleUser, Content: goalContinueTurn}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "hidden continuation partial"}) + if err := sess.Save(path); err != nil { + t.Fatal(err) + } + if err := agent.MarkSessionInFlightTurn(path, start, false); err != nil { + t.Fatal(err) + } + + loaded, err := agent.LoadSession(path) + if err != nil { + t.Fatal(err) + } + exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) + c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path}) + c.Resume(loaded, path) + + msgs := exec.Session().Snapshot() + if len(msgs) != start { + t.Fatalf("resumed messages = %d, want synthetic turn stripped to %d: %+v", len(msgs), start, msgs) + } + if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant || !strings.Contains(last.Content, "[goal:continue]") { + t.Fatalf("last resumed message = %+v, want completed visible turn preserved", last) + } + meta, ok, err := agent.LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err) + } + if meta.InFlightTurn != nil { + t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn) + } } // cancelStrippingRunner adds messages to a session then returns a fixed error, diff --git a/internal/tool/builtin/bash.go b/internal/tool/builtin/bash.go index c0d06d49b0..cf4f708807 100644 --- a/internal/tool/builtin/bash.go +++ b/internal/tool/builtin/bash.go @@ -157,13 +157,12 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) cmd := exec.CommandContext(jobCtx, argv[0], argv[1:]...) cmd.Dir = workDir cmd.Env = cmdEnv - setKillTree(cmd) cmd.WaitDelay = bashWaitDelay cmd.Stdout = out cmd.Stderr = out - runErr := cmd.Run() + tracked, runErr := runShellProcess(cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses)) if shouldReapAfterRun(jobCtx, sh, p.Command, p.PreserveBackgroundProcesses) { - reapTree(cmd) // reap process-group stragglers the job left running (#3702) + reapShellProcess(cmd, tracked) // reap process-group stragglers the job left running (#3702) } return "", runErr }) @@ -181,7 +180,6 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...) cmd.Dir = b.workDir // "" lets exec use the process working directory cmd.Env = cmdEnv - setKillTree(cmd) cmd.WaitDelay = bashWaitDelay var buf bytes.Buffer w := io.Writer(&buf) @@ -190,13 +188,13 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) } cmd.Stdout = w cmd.Stderr = w - err := cmd.Run() + tracked, err := runShellProcess(cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses)) // A foreground command that spawned a lingering child (e.g. `bazel run`'s // server) leaves it in the process group; Wait only reaped the shell leader. // Kill the group so those don't accumulate into an OOM (#3702). On cancel/ - // timeout setKillTree's Cancel already did this; this covers normal exit. + // timeout the command's Cancel path already did this; this covers normal exit. if shouldReapAfterRun(runCtx, sh, p.Command, p.PreserveBackgroundProcesses) { - reapTree(cmd) + reapShellProcess(cmd, tracked) } out := buf.String() @@ -240,6 +238,78 @@ func (b bash) foregroundTimeout() time.Duration { return b.timeout } +type trackedShellProcess struct { + cmd *exec.Cmd + mu sync.Mutex + job uintptr + killed bool +} + +func shouldTrackShellProcess(sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool { + if preserveBackgroundProcesses { + return false + } + return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command) +} + +func runShellProcess(cmd *exec.Cmd, track bool) (*trackedShellProcess, error) { + if !track { + setKillTree(cmd) + return nil, cmd.Run() + } + tracked := &trackedShellProcess{cmd: cmd} + proc.HideWindow(cmd) + cmd.Cancel = func() error { + tracked.kill() + return context.Canceled + } + job, err := proc.StartTracked(cmd) + if err != nil { + return tracked, err + } + tracked.setJob(job) + return tracked, cmd.Wait() +} + +func reapShellProcess(cmd *exec.Cmd, tracked *trackedShellProcess) { + if tracked != nil { + tracked.kill() + return + } + reapTree(cmd) +} + +func (p *trackedShellProcess) setJob(job uintptr) { + if p == nil || job == 0 { + return + } + p.mu.Lock() + killed := p.killed + if !killed { + p.job = job + } + p.mu.Unlock() + if killed { + proc.KillTracked(p.cmd, job) + } +} + +func (p *trackedShellProcess) kill() { + if p == nil { + return + } + p.mu.Lock() + if p.killed { + p.mu.Unlock() + return + } + p.killed = true + job := p.job + p.job = 0 + p.mu.Unlock() + proc.KillTracked(p.cmd, job) +} + // progressWriter forwards each chunk the command writes to a tool.ProgressFunc, // so foreground bash output streams to the frontend as it is produced. type progressWriter struct{ emit tool.ProgressFunc } diff --git a/internal/tool/builtin/bash_cancel_windows_test.go b/internal/tool/builtin/bash_cancel_windows_test.go index fdbc20ba66..e78f9c8205 100644 --- a/internal/tool/builtin/bash_cancel_windows_test.go +++ b/internal/tool/builtin/bash_cancel_windows_test.go @@ -64,6 +64,39 @@ func TestBashCancelKillsWindowsChildProcessTree(t *testing.T) { t.Fatalf("child process %d survived bash cancel", childPID) } +func TestBashWindowsReapsChildAfterForegroundShellExit(t *testing.T) { + powershell, err := exec.LookPath("powershell") + if err != nil { + t.Skip("powershell not found") + } + tmp := t.TempDir() + pidFile := filepath.Join(tmp, "child.pid") + quotedPIDFile := strings.ReplaceAll(pidFile, "'", "''") + command := fmt.Sprintf( + "$p = Start-Process -FilePath powershell -ArgumentList '-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 120' -PassThru; "+ + "Set-Content -LiteralPath '%s' -Value $p.Id", + quotedPIDFile, + ) + args, _ := json.Marshal(map[string]any{"command": command}) + + out, err := (bash{ + shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershell}, + }).Execute(context.Background(), args) + childPID := waitForWindowsPIDFile(t, pidFile) + if err != nil { + killWindowsPID(childPID) + t.Fatalf("foreground command failed: %v (out=%q)", err, out) + } + for i := 0; i < 50; i++ { + if !windowsProcessAlive(childPID) { + return + } + time.Sleep(100 * time.Millisecond) + } + killWindowsPID(childPID) + t.Fatalf("child process %d survived foreground bash cleanup", childPID) +} + func waitForWindowsPIDFile(t *testing.T, path string) int { t.Helper() deadline := time.Now().Add(10 * time.Second)