diff --git a/src/features/chat/chat-state.ts b/src/features/chat/chat-state.ts index e2b4b277..baa4ad24 100644 --- a/src/features/chat/chat-state.ts +++ b/src/features/chat/chat-state.ts @@ -100,6 +100,7 @@ interface ChatComposerState { interface ChatUiState { openDetails: ReadonlySet; + toolbarPanel: "history" | "chat-actions" | "status-panel" | null; } export interface ChatState { @@ -696,6 +697,7 @@ function initialComposerState(): ChatComposerState { function initialUiState(): ChatUiState { return { openDetails: new Set(), + toolbarPanel: null, }; } @@ -731,6 +733,7 @@ function cloneChatState(state: ChatState): ChatState { }, ui: { openDetails: new Set(state.ui.openDetails), + toolbarPanel: state.ui.toolbarPanel, }, }; } @@ -740,18 +743,8 @@ function isTranscriptAction(action: ChatSliceAction): action is TranscriptAction } function setPanelSlice(state: ChatUiState, panel: "history" | "chat-actions" | "status-panel" | null, toggle: boolean): ChatUiState { - const currentPanel = state.openDetails.has("history") - ? "history" - : state.openDetails.has("chat-actions") - ? "chat-actions" - : state.openDetails.has("status-panel") - ? "status-panel" - : null; - const nextPanel = toggle && currentPanel === panel ? null : panel; - const toolbarPanelKeys = new Set(["history", "chat-actions", "status-panel"]); - return patchObject(state, { - openDetails: nextPanel ? new Set([nextPanel]) : new Set([...state.openDetails].filter((key) => !toolbarPanelKeys.has(key))), - }); + const nextPanel = toggle && state.toolbarPanel === panel ? null : panel; + return patchObject(state, { toolbarPanel: nextPanel }); } function setDetailOpenSlice(state: ChatUiState, key: string, open: boolean): ChatUiState { diff --git a/src/features/chat/panel/model/toolbar.ts b/src/features/chat/panel/model/toolbar.ts index badc3528..1340e7d7 100644 --- a/src/features/chat/panel/model/toolbar.ts +++ b/src/features/chat/panel/model/toolbar.ts @@ -7,9 +7,9 @@ import type { ConnectionDiagnosticsModelInput, ToolbarThreadRow, ToolbarViewMode export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel { const { state, snapshot } = input; const limit = rateLimitSummary(snapshot); - const historyOpen = state.ui.openDetails.has("history"); - const chatActionsOpen = state.ui.openDetails.has("chat-actions"); - const statusPanelOpen = state.ui.openDetails.has("status-panel"); + const historyOpen = state.ui.toolbarPanel === "history"; + const chatActionsOpen = state.ui.toolbarPanel === "chat-actions"; + const statusPanelOpen = state.ui.toolbarPanel === "status-panel"; return { newChatDisabled: input.turnBusy, chatActionsOpen, diff --git a/src/features/chat/panel/snapshot.ts b/src/features/chat/panel/snapshot.ts index 2b6c1171..38e6fab3 100644 --- a/src/features/chat/panel/snapshot.ts +++ b/src/features/chat/panel/snapshot.ts @@ -37,7 +37,7 @@ export function toolbarSlotSnapshot(state: ChatState, connected: boolean): ChatP state.runtime.requestedApprovalsReviewer, state.runtime.requestedModel, state.runtime.requestedReasoningEffort, - openDetailsSignature(state.ui.openDetails), + state.ui.toolbarPanel, state.threadList.threadsLoaded, threadListSignature(state.threadList.listedThreads), modelsSignature(state.connection.availableModels), @@ -131,10 +131,6 @@ function stableSignature(value: unknown): string { return JSON.stringify(value); } -function openDetailsSignature(openDetails: ReadonlySet): string { - return [...openDetails].sort().join("\n"); -} - function messageStreamOpenDetailsSignature(openDetails: ReadonlySet): string { return [...openDetails].filter(isMessageStreamOpenDetailKey).sort().join("\n"); } diff --git a/src/features/chat/panel/toolbar-controller.ts b/src/features/chat/panel/toolbar-controller.ts index f0eb0101..8134e0a3 100644 --- a/src/features/chat/panel/toolbar-controller.ts +++ b/src/features/chat/panel/toolbar-controller.ts @@ -94,11 +94,7 @@ export class ToolbarPanelController { } private hasOpenPanel(): boolean { - return ( - this.state.ui.openDetails.has("history") || - this.state.ui.openDetails.has("chat-actions") || - this.state.ui.openDetails.has("status-panel") - ); + return this.state.ui.toolbarPanel !== null; } private close(): void { diff --git a/tests/features/chat/chat-state-reducer.test.ts b/tests/features/chat/chat-state-reducer.test.ts index 23c4dbe3..e4f6e6c3 100644 --- a/tests/features/chat/chat-state-reducer.test.ts +++ b/tests/features/chat/chat-state-reducer.test.ts @@ -163,16 +163,16 @@ describe("chatReducer", () => { it("clones map and set backed state when updating turn diffs and open panels", () => { const state = createChatState(); state.transcript.turnDiffs = new Map([["turn", "old"]]); - state.ui.openDetails = new Set(["status-panel"]); + state.ui.toolbarPanel = "status-panel"; const withDiff = chatReducer(state, { type: "transcript/turn-diff-updated", turnId: "turn", diff: "new" }); const withHistoryPanel = chatReducer(withDiff, { type: "ui/panel-set", panel: "history" }); expect(withDiff.transcript.turnDiffs).not.toBe(state.transcript.turnDiffs); expect(withDiff.transcript.turnDiffs.get("turn")).toBe("new"); - expect(withHistoryPanel.ui.openDetails).not.toBe(withDiff.ui.openDetails); - expect(withHistoryPanel.ui.openDetails.has("history")).toBe(true); - expect(withHistoryPanel.ui.openDetails.has("status-panel")).toBe(false); + expect(withHistoryPanel.ui).not.toBe(withDiff.ui); + expect(withHistoryPanel.ui.toolbarPanel).toBe("history"); + expect(withHistoryPanel.ui.openDetails).toBe(withDiff.ui.openDetails); }); it("returns the same state reference for no-op actions", () => { @@ -352,15 +352,13 @@ describe("chatReducer", () => { let state = createChatState(); state = chatReducer(state, { type: "ui/panel-set", panel: "history" }); - expect(state.ui.openDetails.has("history")).toBe(true); + expect(state.ui.toolbarPanel).toBe("history"); state = chatReducer(state, { type: "ui/panel-set", panel: "chat-actions" }); - expect(state.ui.openDetails.has("history")).toBe(false); - expect(state.ui.openDetails.has("chat-actions")).toBe(true); + expect(state.ui.toolbarPanel).toBe("chat-actions"); state = chatReducer(state, { type: "ui/panel-set", panel: "status-panel" }); - expect(state.ui.openDetails.has("chat-actions")).toBe(false); - expect(state.ui.openDetails.has("status-panel")).toBe(true); + expect(state.ui.toolbarPanel).toBe("status-panel"); }); it("updates remembered details and user input drafts through typed UI actions", () => { diff --git a/tests/features/chat/panel/toolbar-controller.test.ts b/tests/features/chat/panel/toolbar-controller.test.ts index 0dc20269..67cc6c2a 100644 --- a/tests/features/chat/panel/toolbar-controller.test.ts +++ b/tests/features/chat/panel/toolbar-controller.test.ts @@ -36,7 +36,7 @@ describe("ToolbarPanelController", () => { scheduleRender, }); controller.toggleHistory(); - expect(stateStore.getState().ui.openDetails.has("history")).toBe(true); + expect(stateStore.getState().ui.toolbarPanel).toBe("history"); controller.closeOnOutsidePointer({ target: document.createElement("button"), @@ -45,7 +45,7 @@ describe("ToolbarPanelController", () => { renameEditing: false, }); - expect(stateStore.getState().ui.openDetails.size).toBe(0); + expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(scheduleRender).toHaveBeenCalledWith({ forceSlots: true }); }); }); diff --git a/tests/features/chat/session/reconnect-actions.test.ts b/tests/features/chat/session/reconnect-actions.test.ts index 3b963f60..a73ac62a 100644 --- a/tests/features/chat/session/reconnect-actions.test.ts +++ b/tests/features/chat/session/reconnect-actions.test.ts @@ -41,7 +41,7 @@ describe("createChatReconnectActions", () => { await controller.reconnectPanel(); - expect(stateStore.getState().ui.openDetails.size).toBe(0); + expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(host.invalidateConnectionWork).toHaveBeenCalledOnce(); expect(host.invalidateResumeWork).toHaveBeenCalledOnce(); expect(host.clearDeferredDiagnostics).toHaveBeenCalledOnce(); diff --git a/tests/features/chat/threads/thread-selection-controller.test.ts b/tests/features/chat/threads/thread-selection-controller.test.ts index 5f474400..830d8fa7 100644 --- a/tests/features/chat/threads/thread-selection-controller.test.ts +++ b/tests/features/chat/threads/thread-selection-controller.test.ts @@ -74,7 +74,7 @@ describe("ThreadSelectionController", () => { await controller.selectThreadFromToolbar("thread"); - expect(stateStore.getState().ui.openDetails.size).toBe(0); + expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(host.closeForThreadSelection).toHaveBeenCalledOnce(); expect(host.resumeThread).toHaveBeenCalledWith("thread"); }); @@ -87,7 +87,7 @@ describe("ThreadSelectionController", () => { await controller.selectThreadFromToolbar("other"); - expect(stateStore.getState().ui.openDetails.has("history")).toBe(true); + expect(stateStore.getState().ui.toolbarPanel).toBe("history"); expect(host.addSystemMessage).not.toHaveBeenCalled(); expect(host.closeForThreadSelection).not.toHaveBeenCalled(); expect(host.resumeThread).not.toHaveBeenCalled(); diff --git a/tests/features/chat/turns/plan-implementation-actions.test.ts b/tests/features/chat/turns/plan-implementation-actions.test.ts index 12b2d377..754a85fd 100644 --- a/tests/features/chat/turns/plan-implementation-actions.test.ts +++ b/tests/features/chat/turns/plan-implementation-actions.test.ts @@ -75,7 +75,7 @@ describe("createPlanImplementationActions", () => { expect(ensureConnected).toHaveBeenCalledOnce(); expect(stateStore.getState().runtime.selectedCollaborationMode).toBe("default"); - expect(stateStore.getState().ui.openDetails.has("status-panel")).toBe(false); + expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(sendTurnText).toHaveBeenCalledWith("Please implement this plan."); }); diff --git a/tests/features/chat/view-model.test.ts b/tests/features/chat/view-model.test.ts index d1c36280..d71c8672 100644 --- a/tests/features/chat/view-model.test.ts +++ b/tests/features/chat/view-model.test.ts @@ -26,7 +26,7 @@ describe("chat view model", () => { state.activeThread.id = "thread-1"; state.threadList.listedThreads = [threadFixture("thread-1", "Active"), threadFixture("thread-2", "Other")]; state.turn.lifecycle = { kind: "running", turnId: "turn" }; - state.ui.openDetails = new Set(["history"]); + state.ui.toolbarPanel = "history"; state.connection.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }); state.connection.appServerDiagnostics = createAppServerDiagnostics(); diff --git a/tests/features/chat/view-snapshot.test.ts b/tests/features/chat/view-snapshot.test.ts index 4647ee92..45a54b4a 100644 --- a/tests/features/chat/view-snapshot.test.ts +++ b/tests/features/chat/view-snapshot.test.ts @@ -31,7 +31,8 @@ describe("chat view snapshots", () => { const state = createChatState(); const base = messagesSlotSnapshot(state, ""); - state.ui.openDetails = new Set(["history", "chat-actions", "status-panel", "goal:editor"]); + state.ui.toolbarPanel = "history"; + state.ui.openDetails = new Set(["goal:editor"]); expect(messagesSlotSnapshot(state, "")).toBe(base); state.ui.openDetails = new Set(["message:item:expanded"]);