Separate toolbar panel state from open details

This commit is contained in:
murashit 2026-06-10 09:24:02 +09:00
parent ce0ebb67a1
commit e2e63780f6
11 changed files with 26 additions and 42 deletions

View file

@ -100,6 +100,7 @@ interface ChatComposerState {
interface ChatUiState {
openDetails: ReadonlySet<string>;
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 {

View file

@ -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,

View file

@ -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>): string {
return [...openDetails].sort().join("\n");
}
function messageStreamOpenDetailsSignature(openDetails: ReadonlySet<string>): string {
return [...openDetails].filter(isMessageStreamOpenDetailKey).sort().join("\n");
}

View file

@ -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 {

View file

@ -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", () => {

View file

@ -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 });
});
});

View file

@ -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();

View file

@ -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();

View file

@ -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.");
});

View file

@ -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();

View file

@ -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"]);