Clarify chat state reset and placeholders

This commit is contained in:
murashit 2026-06-04 08:18:22 +09:00
parent 0b379ecc93
commit cd43d0c96a
2 changed files with 136 additions and 70 deletions

View file

@ -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<ChatState, "displayItems" | "turnDiffs" | "messagesPinnedToBottom"> {
return {
displayItems: [],
turnDiffs: new Map(),
messagesPinnedToBottom: true,
};
}
function initialPendingRequestState(): Pick<ChatState, "approvals" | "pendingUserInputs" | "userInputDrafts"> {
return {
approvals: [],
pendingUserInputs: [],
userInputDrafts: new Map(),
};
}
function initialHistoryState(): Pick<ChatState, "historyCursor" | "loadingHistory"> {
return {
historyCursor: null,
loadingHistory: false,
};
}
function initialComposerState(): Pick<
ChatState,
"composerDraft" | "composerSuggestSelected" | "composerSuggestions" | "composerSuggestionsDismissedSignature"
> {
return {
composerDraft: "",
composerSuggestSelected: 0,
composerSuggestions: [],
composerSuggestionsDismissedSignature: null,
};
}
function initialPanelUiState(): Pick<ChatState, "runtimePicker" | "openDetails"> {
return {
runtimePicker: null,
openDetails: new Set(),
};
}
export function cloneChatState(state: ChatState): ChatState {
return {
...state,

View file

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