diff --git a/src/features/chat/chat-state.ts b/src/features/chat/chat-state.ts index b92a2f87..f288a84c 100644 --- a/src/features/chat/chat-state.ts +++ b/src/features/chat/chat-state.ts @@ -210,41 +210,22 @@ export function createChatState(): ChatState { activeThreadId: null, activeThreadCwd: null, turnLifecycle: { kind: "idle" }, - activeModel: null, - activeReasoningEffort: null, - activeCollaborationMode: "default", - activeServiceTier: null, - activeApprovalPolicy: null, - activeApprovalsReviewer: null, - activePermissionProfile: null, + ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, appServerDiagnostics: createAppServerDiagnostics(), - requestedModel: unchangedRuntimeSetting(), - requestedReasoningEffort: unchangedRuntimeSetting(), - requestedApprovalsReviewer: unchangedRuntimeSetting(), - selectedCollaborationMode: "default", - requestedServiceTier: unchangedRuntimeSetting(), + ...initialRequestedRuntimeState(), tokenUsage: null, rateLimit: null, - displayItems: [], - turnDiffs: new Map(), - approvals: [], - pendingUserInputs: [], - userInputDrafts: new Map(), + ...initialDisplayState(), + ...initialPendingRequestState(), listedThreads: [], threadsLoaded: false, - historyCursor: null, - loadingHistory: false, - composerDraft: "", - runtimePicker: null, + ...initialHistoryState(), + ...initialComposerState(), + ...initialPanelUiState(), availableModels: [], availableSkills: [], reportedLogs: new Set(), - composerSuggestSelected: 0, - composerSuggestions: [], - composerSuggestionsDismissedSignature: null, - messagesPinnedToBottom: true, - openDetails: new Set(), }; } @@ -340,16 +321,10 @@ function reduceThreadState(state: ChatState, action: ThreadAction): ChatState { activePermissionProfile: action.activePermissionProfile, activeThreadCreationCliVersion: action.thread.cliVersion, tokenUsage: null, - historyCursor: null, - loadingHistory: false, + ...initialHistoryState(), + ...initialPendingRequestState(), + ...initialComposerState(), turnDiffs: new Map(), - approvals: [], - pendingUserInputs: [], - userInputDrafts: new Map(), - composerDraft: "", - composerSuggestSelected: 0, - composerSuggestions: [], - composerSuggestionsDismissedSignature: null, displayItems: action.displayItems ?? [], listedThreads: action.listedThreads ?? state.listedThreads, messagesPinnedToBottom: action.forceMessagesToBottom ?? true, @@ -386,17 +361,10 @@ function reduceThreadState(state: ChatState, action: ThreadAction): ChatState { patchChatState(state, { activeThreadId: action.threadId, activeThreadCwd: null, - activeModel: null, - activeReasoningEffort: null, - activeCollaborationMode: "default", - activeServiceTier: null, - activeApprovalPolicy: null, - activeApprovalsReviewer: null, - activePermissionProfile: null, + ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, tokenUsage: null, - historyCursor: null, - loadingHistory: false, + ...initialHistoryState(), displayItems: [action.item], turnDiffs: new Map(), messagesPinnedToBottom: true, @@ -565,9 +533,7 @@ function reduceRuntimeState(state: ChatState, action: RuntimeAction): ChatState export function clearActiveTurnState(state: ChatState): ChatState { return patchChatState(state, { turnLifecycle: transitionChatTurnLifecycleState(state.turnLifecycle, { type: "cleared" }), - approvals: [], - pendingUserInputs: [], - userInputDrafts: new Map(), + ...initialPendingRequestState(), }); } @@ -576,37 +542,19 @@ export function clearActiveThreadState(state: ChatState): ChatState { patchChatState(state, { activeThreadId: null, activeThreadCwd: null, - activeModel: null, - activeReasoningEffort: null, - activeCollaborationMode: "default", - activeServiceTier: null, - activeApprovalPolicy: null, - activeApprovalsReviewer: null, - activePermissionProfile: null, + ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, tokenUsage: null, - historyCursor: null, - loadingHistory: false, - displayItems: [], - turnDiffs: new Map(), - composerDraft: "", - composerSuggestSelected: 0, - composerSuggestions: [], - composerSuggestionsDismissedSignature: null, - messagesPinnedToBottom: true, + ...initialHistoryState(), + ...initialDisplayState(), + ...initialComposerState(), }), ); } export function clearConnectionScopedState(state: ChatState): ChatState { return patchChatState(clearActiveTurnState(state), { - activeModel: null, - activeReasoningEffort: null, - activeCollaborationMode: "default", - activeServiceTier: null, - activeApprovalPolicy: null, - activeApprovalsReviewer: null, - activePermissionProfile: null, + ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, rateLimit: null, listedThreads: [], @@ -618,6 +566,82 @@ export function clearConnectionScopedState(state: ChatState): ChatState { }); } +function initialActiveRuntimeState(): Pick< + ChatState, + | "activeModel" + | "activeReasoningEffort" + | "activeCollaborationMode" + | "activeServiceTier" + | "activeApprovalPolicy" + | "activeApprovalsReviewer" + | "activePermissionProfile" +> { + return { + activeModel: null, + activeReasoningEffort: null, + activeCollaborationMode: "default", + activeServiceTier: null, + activeApprovalPolicy: null, + activeApprovalsReviewer: null, + activePermissionProfile: null, + }; +} + +function initialRequestedRuntimeState(): Pick< + ChatState, + "requestedModel" | "requestedReasoningEffort" | "requestedApprovalsReviewer" | "selectedCollaborationMode" | "requestedServiceTier" +> { + return { + requestedModel: unchangedRuntimeSetting(), + requestedReasoningEffort: unchangedRuntimeSetting(), + requestedApprovalsReviewer: unchangedRuntimeSetting(), + selectedCollaborationMode: "default", + requestedServiceTier: unchangedRuntimeSetting(), + }; +} + +function initialDisplayState(): Pick { + return { + displayItems: [], + turnDiffs: new Map(), + messagesPinnedToBottom: true, + }; +} + +function initialPendingRequestState(): Pick { + return { + approvals: [], + pendingUserInputs: [], + userInputDrafts: new Map(), + }; +} + +function initialHistoryState(): Pick { + return { + historyCursor: null, + loadingHistory: false, + }; +} + +function initialComposerState(): Pick< + ChatState, + "composerDraft" | "composerSuggestSelected" | "composerSuggestions" | "composerSuggestionsDismissedSignature" +> { + return { + composerDraft: "", + composerSuggestSelected: 0, + composerSuggestions: [], + composerSuggestionsDismissedSignature: null, + }; +} + +function initialPanelUiState(): Pick { + return { + runtimePicker: null, + openDetails: new Set(), + }; +} + export function cloneChatState(state: ChatState): ChatState { return { ...state, diff --git a/tests/features/chat/chat-state-reducer.test.ts b/tests/features/chat/chat-state-reducer.test.ts index d8dfbb2c..9ed11a2d 100644 --- a/tests/features/chat/chat-state-reducer.test.ts +++ b/tests/features/chat/chat-state-reducer.test.ts @@ -115,6 +115,48 @@ describe("chatReducer", () => { expect(next.displayItems).toEqual([]); }); + it("keeps composer state when restoring a thread placeholder", () => { + const state = createChatState(); + state.activeThreadId = "previous-thread"; + state.turnLifecycle = { kind: "running", turnId: "previous-turn" }; + state.activeModel = "gpt-5.1"; + state.historyCursor = "cursor"; + state.loadingHistory = true; + state.composerDraft = "draft in this panel"; + state.composerSuggestSelected = 1; + state.composerSuggestions = [suggestion("/resume")]; + state.composerSuggestionsDismissedSignature = "dismissed"; + state.displayItems = [message("previous-message")]; + state.turnDiffs = new Map([["previous-turn", "@@"]]); + state.approvals = [approval(1)]; + state.pendingUserInputs = [userInput(2)]; + state.userInputDrafts = new Map([["2:note", "answer"]]); + state.messagesPinnedToBottom = false; + const placeholder = message("placeholder"); + + const next = chatReducer(state, { + type: "thread/restored-placeholder", + threadId: "restored-thread", + item: placeholder, + }); + + expect(next.activeThreadId).toBe("restored-thread"); + expect(activeTurnId(next)).toBeNull(); + expect(next.activeModel).toBeNull(); + expect(next.historyCursor).toBeNull(); + expect(next.loadingHistory).toBe(false); + expect(next.displayItems).toEqual([placeholder]); + expect(next.turnDiffs.size).toBe(0); + expect(next.approvals).toEqual([]); + expect(next.pendingUserInputs).toEqual([]); + expect(next.userInputDrafts.size).toBe(0); + expect(next.messagesPinnedToBottom).toBe(true); + expect(next.composerDraft).toBe("draft in this panel"); + expect(next.composerSuggestSelected).toBe(1); + expect(next.composerSuggestions).toEqual([suggestion("/resume")]); + expect(next.composerSuggestionsDismissedSignature).toBe("dismissed"); + }); + it("clones map and set backed state when updating turn diffs and open panels", () => { const state = createChatState(); state.turnDiffs = new Map([["turn", "old"]]);