diff --git a/src/features/chat/application/pending-requests/state.ts b/src/features/chat/application/pending-requests/state.ts index 39b7f752..6c6c1bf6 100644 --- a/src/features/chat/application/pending-requests/state.ts +++ b/src/features/chat/application/pending-requests/state.ts @@ -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(current: T, patch: Partial): T { - if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current; - return { ...current, ...patch }; -} diff --git a/src/features/chat/application/state/clone.ts b/src/features/chat/application/state/clone.ts new file mode 100644 index 00000000..290d5e3e --- /dev/null +++ b/src/features/chat/application/state/clone.ts @@ -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), + }; +} diff --git a/src/features/chat/application/state/message-stream.ts b/src/features/chat/application/state/message-stream.ts index 0d4908b2..c580e37c 100644 --- a/src/features/chat/application/state/message-stream.ts +++ b/src/features/chat/application/state/message-stream.ts @@ -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(current: T, patch: Partial): T { - if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current; - return { ...current, ...patch }; -} - -function definedPatch(key: Key, value: Value | undefined): Partial> { - return value === undefined ? {} : ({ [key]: value } as Partial>); -} diff --git a/src/features/chat/application/state/patch.ts b/src/features/chat/application/state/patch.ts new file mode 100644 index 00000000..3a120f96 --- /dev/null +++ b/src/features/chat/application/state/patch.ts @@ -0,0 +1,8 @@ +export function patchObject(current: T, patch: Partial): 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: Key, value: Value | undefined): Partial> { + return value === undefined ? {} : ({ [key]: value } as Partial>); +} diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index c9de9039..fe824547 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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(current: T, patch: Partial): 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 { - if (Object.entries(patch).every(([key, value]) => Object.is(state[key as keyof ChatState], value))) return state; - return { ...state, ...patch }; -} - -function definedPatch(key: Key, value: Value | undefined): Partial> { - return value === undefined ? {} : ({ [key]: value } as Partial>); + return patchObject(state, patch); } diff --git a/src/features/chat/application/state/store.ts b/src/features/chat/application/state/store.ts index a370d252..8d32bcd3 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -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; diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index da56516f..144a3448 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -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, keep: (value: string) => b } return next ?? values; } - -function patchObject(current: T, patch: Partial): T { - if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current; - return { ...current, ...patch }; -}