Extract chat state utilities

This commit is contained in:
murashit 2026-06-22 20:19:13 +09:00
parent d38bdaf32c
commit f474ff49c7
7 changed files with 76 additions and 91 deletions

View file

@ -7,6 +7,7 @@ import {
type PendingRequestId,
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
import { patchObject } from "../state/patch";
export interface ChatRequestState {
readonly approvals: readonly PendingApproval[];
@ -107,8 +108,3 @@ function setMcpElicitationDraftSlice(state: ChatRequestState, key: string, value
if (state.mcpElicitationDrafts.get(key) === value) return state;
return patchObject(state, { mcpElicitationDrafts: new Map([...state.mcpElicitationDrafts, [key, value]]) });
}
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
}

View file

@ -0,0 +1,61 @@
import type { ChatState } from "./root-reducer";
import { cloneDisclosureUiState } from "./ui-state";
import type { ChatMessageStreamActiveSegment, ChatMessageStreamState } from "./message-stream";
export function cloneChatState(state: ChatState): ChatState {
return {
connection: {
...state.connection,
availableModels: [...state.connection.availableModels],
availableSkills: [...state.connection.availableSkills],
},
threadList: {
listedThreads: [...state.threadList.listedThreads],
threadsLoaded: state.threadList.threadsLoaded,
},
activeThread: { ...state.activeThread },
runtime: { ...state.runtime },
turn: { lifecycle: state.turn.lifecycle },
messageStream: cloneMessageStreamState(state.messageStream),
requests: {
approvals: [...state.requests.approvals],
pendingUserInputs: [...state.requests.pendingUserInputs],
pendingMcpElicitations: [...state.requests.pendingMcpElicitations],
userInputDrafts: new Map(state.requests.userInputDrafts),
mcpElicitationDrafts: new Map(state.requests.mcpElicitationDrafts),
},
composer: {
...state.composer,
suggestions: [...state.composer.suggestions],
},
ui: {
toolbarPanel: state.ui.toolbarPanel,
archiveConfirmThreadId: state.ui.archiveConfirmThreadId,
rename: { ...state.ui.rename },
goalEditor: { ...state.ui.goalEditor },
messageActionMenu: { ...state.ui.messageActionMenu },
disclosures: cloneDisclosureUiState(state.ui.disclosures),
},
};
}
function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStreamState {
return {
stableItems: [...state.stableItems],
activeSegment: cloneActiveSegment(state.activeSegment),
turnDiffs: new Map(state.turnDiffs),
historyCursor: state.historyCursor,
loadingHistory: state.loadingHistory,
reportedLogs: new Set(state.reportedLogs),
};
}
function cloneActiveSegment(segment: ChatMessageStreamActiveSegment | null): ChatMessageStreamActiveSegment | null {
if (!segment) return null;
return {
turnId: segment.turnId,
items: [...segment.items],
indexById: new Map(segment.indexById),
indexBySourceItemId: new Map(segment.indexBySourceItemId),
};
}

View file

@ -8,6 +8,7 @@ import {
streamedTextMessageStreamItem,
streamedToolOutputMessageStreamItem,
} from "../../domain/message-stream/factories/streaming-items";
import { definedPatch, patchObject } from "./patch";
export interface ChatMessageStreamActiveSegment {
readonly turnId: string | null;
@ -545,12 +546,3 @@ function promptMessageForTurn(items: readonly MessageStreamItem[], turnId: strin
const item = classification?.item;
return item?.kind === "message" ? item : null;
}
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
}
function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}

View file

@ -0,0 +1,8 @@
export function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
}
export function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}

View file

@ -49,7 +49,6 @@ import {
messageStreamWithActiveTurnItems,
messageStreamWithItems,
reduceMessageStreamSlice,
type ChatMessageStreamActiveSegment,
type ChatMessageStreamState,
type MessageStreamAction,
} from "./message-stream";
@ -70,7 +69,6 @@ import {
import {
clearAllRequestDisclosures,
clearResolvedRequestDisclosures,
cloneDisclosureUiState,
initialUiState,
isUiAction,
maybeClearGoalObjectiveExpansion,
@ -79,6 +77,7 @@ import {
type UiAction,
} from "./ui-state";
import { STATUS_TURN_RUNNING } from "./status-text";
import { definedPatch, patchObject } from "./patch";
export { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatTurnState, type PendingTurnStart } from "../conversation/turn-state";
export type { ChatMessageStreamState } from "./message-stream";
@ -658,64 +657,6 @@ function initialComposerState(): ChatComposerState {
};
}
export function cloneChatState(state: ChatState): ChatState {
return {
connection: {
...state.connection,
availableModels: [...state.connection.availableModels],
availableSkills: [...state.connection.availableSkills],
},
threadList: {
listedThreads: [...state.threadList.listedThreads],
threadsLoaded: state.threadList.threadsLoaded,
},
activeThread: { ...state.activeThread },
runtime: { ...state.runtime },
turn: { lifecycle: state.turn.lifecycle },
messageStream: cloneMessageStreamState(state.messageStream),
requests: {
approvals: [...state.requests.approvals],
pendingUserInputs: [...state.requests.pendingUserInputs],
pendingMcpElicitations: [...state.requests.pendingMcpElicitations],
userInputDrafts: new Map(state.requests.userInputDrafts),
mcpElicitationDrafts: new Map(state.requests.mcpElicitationDrafts),
},
composer: {
...state.composer,
suggestions: [...state.composer.suggestions],
},
ui: {
toolbarPanel: state.ui.toolbarPanel,
archiveConfirmThreadId: state.ui.archiveConfirmThreadId,
rename: { ...state.ui.rename },
goalEditor: { ...state.ui.goalEditor },
messageActionMenu: { ...state.ui.messageActionMenu },
disclosures: cloneDisclosureUiState(state.ui.disclosures),
},
};
}
function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStreamState {
return {
stableItems: [...state.stableItems],
activeSegment: cloneActiveSegment(state.activeSegment),
turnDiffs: new Map(state.turnDiffs),
historyCursor: state.historyCursor,
loadingHistory: state.loadingHistory,
reportedLogs: new Set(state.reportedLogs),
};
}
function cloneActiveSegment(segment: ChatMessageStreamActiveSegment | null): ChatMessageStreamActiveSegment | null {
if (!segment) return null;
return {
turnId: segment.turnId,
items: [...segment.items],
indexById: new Map(segment.indexById),
indexBySourceItemId: new Map(segment.indexBySourceItemId),
};
}
function setComposerSuggestionsSlice(
state: ChatComposerState,
suggestions: readonly ComposerSuggestion[],
@ -751,16 +692,6 @@ function composerSuggestionsEqual(left: readonly ComposerSuggestion[], right: re
});
}
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
}
function patchChatState(state: ChatState, patch: Partial<ChatState>): ChatState {
if (Object.entries(patch).every(([key, value]) => Object.is(state[key as keyof ChatState], value))) return state;
return { ...state, ...patch };
}
function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
return patchObject(state, patch);
}

View file

@ -1,4 +1,5 @@
import { chatReducer, cloneChatState, createChatState, type ChatAction, type ChatState } from "./root-reducer";
import { cloneChatState } from "./clone";
import { chatReducer, createChatState, type ChatAction, type ChatState } from "./root-reducer";
export interface ChatStateStore {
getState(): ChatState;

View file

@ -9,6 +9,7 @@ import {
type ThreadRenameLifecycleState,
} from "../../../threads/rename-lifecycle";
import type { DisclosureSetAction } from "./actions";
import { patchObject } from "./patch";
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
@ -397,8 +398,3 @@ function filterStringSet(values: ReadonlySet<string>, keep: (value: string) => b
}
return next ?? values;
}
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };
}