mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(chat): model panel thread phase explicitly
This commit is contained in:
parent
a099e1fffc
commit
d202849313
46 changed files with 413 additions and 267 deletions
|
|
@ -18,7 +18,7 @@ import type { TurnTranscriptSummary } from "../../../../domain/threads/transcrip
|
|||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||
import type { LocalIdSource } from "../../application/local-id-source";
|
||||
import type { ChatAction, ChatState } from "../../application/state/root-reducer";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
import { activeTurnId } from "../../application/turns/turn-state";
|
||||
import {
|
||||
|
|
@ -120,7 +120,7 @@ function handleNotification(
|
|||
|
||||
function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void {
|
||||
const current = state(context);
|
||||
const route = routeServerRequest(request, { activeThreadId: current.activeThread.id, activeTurnId: activeTurnId(current) });
|
||||
const route = routeServerRequest(request, { activeThreadId: activeThreadId(current), activeTurnId: activeTurnId(current) });
|
||||
switch (route.kind) {
|
||||
case "approval":
|
||||
dispatch(context, { type: "request/approval-queued", approval: route.approval });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
|
|||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
|
||||
import type { ChatAction, ChatState } from "../../application/state/root-reducer";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan";
|
||||
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
||||
import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing";
|
||||
|
|
@ -40,7 +40,7 @@ export function planChatNotification(
|
|||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
const route = routeServerNotification(notification, {
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadId: activeThreadId(state),
|
||||
activeTurnId: activeTurnIdForState(state),
|
||||
});
|
||||
switch (route.kind) {
|
||||
|
|
@ -73,7 +73,7 @@ function runtimeEventsPlan(
|
|||
}
|
||||
|
||||
function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] {
|
||||
if (state.activeThread.lifetime?.kind === "ephemeral") return [];
|
||||
if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return [];
|
||||
switch (outcome.type) {
|
||||
case "turn-started":
|
||||
return [
|
||||
|
|
@ -153,7 +153,7 @@ function planThreadLifecycle(
|
|||
},
|
||||
});
|
||||
case "thread/settings/updated":
|
||||
if (state.activeThread.id !== notification.params.threadId) return EMPTY_PLAN;
|
||||
if (activeThreadId(state) !== notification.params.threadId) return EMPTY_PLAN;
|
||||
return actionPlan(activeThreadSettingsAppliedAction(notification.params.threadSettings));
|
||||
case "thread/goal/updated":
|
||||
return threadGoalPlan(state, notification.params.threadId, notification.params.goal, localItemId);
|
||||
|
|
@ -176,7 +176,7 @@ function threadStartedPlan(
|
|||
event: { type: "thread-started", thread },
|
||||
},
|
||||
];
|
||||
if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) {
|
||||
if (activeThreadId(state) === notification.params.thread.id) {
|
||||
return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects };
|
||||
}
|
||||
return { actions: [], effects };
|
||||
|
|
@ -188,9 +188,10 @@ function threadGoalPlan(
|
|||
goal: Extract<ThreadLifecycleNotification, { method: "thread/goal/updated" }>["params"]["goal"] | null,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
if (state.activeThread.id !== threadId) return EMPTY_PLAN;
|
||||
const activeThread = activeThreadState(state);
|
||||
if (!activeThread || activeThread.id !== threadId) return EMPTY_PLAN;
|
||||
const actions: ChatAction[] = [{ type: "active-thread/goal-set", goal }];
|
||||
const item = goalChangeItem(localItemId("goal"), state.activeThread.goal, goal);
|
||||
const item = goalChangeItem(localItemId("goal"), activeThread.goal, goal);
|
||||
if (item) actions.push({ type: "thread-stream/item-upserted", item });
|
||||
return { actions, effects: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ChatConnectionPhase } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatConnectionPhase } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
||||
const STATUS_RECONNECTING = "Reconnecting...";
|
||||
|
|
@ -20,7 +20,7 @@ export async function reconnectPanel(
|
|||
host: ChatReconnectActionsHost,
|
||||
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
|
||||
): Promise<boolean> {
|
||||
const threadId = target ? target.resumeThreadId : host.stateStore.getState().activeThread.id;
|
||||
const threadId = target ? target.resumeThreadId : activeThreadId(host.stateStore.getState());
|
||||
const isCurrent = target?.isCurrent ?? (() => true);
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
host.invalidateConnectionWork();
|
||||
|
|
@ -36,7 +36,7 @@ export async function reconnectPanel(
|
|||
try {
|
||||
await host.resumeThread(threadId);
|
||||
if (!isCurrent()) return false;
|
||||
return host.stateStore.getState().activeThread.id === threadId;
|
||||
return activeThreadId(host.stateStore.getState()) === threadId;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
replaceMcpServerStatusDiagnostics,
|
||||
} from "../../../../domain/server/diagnostics";
|
||||
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
|
||||
import { activeThreadId } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { ServerDiagnosticsTransport } from "./metadata-transport";
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ async function refreshServerDiagnostics(
|
|||
): Promise<boolean> {
|
||||
const initialDiagnostics = currentPanelDiagnostics(host);
|
||||
const state = host.stateStore.getState();
|
||||
const activeThreadId = state.activeThread.id;
|
||||
const threadId = activeThreadId(state);
|
||||
const metadataSnapshot = host.appServerMetadataSnapshot();
|
||||
const cachedSkills =
|
||||
options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills);
|
||||
|
|
@ -54,7 +55,7 @@ async function refreshServerDiagnostics(
|
|||
? undefined
|
||||
: (metadataSnapshot?.serverDiagnostics.probes.skills ?? state.connection.serverDiagnostics.probes.skills);
|
||||
const request = {
|
||||
threadId: activeThreadId,
|
||||
threadId,
|
||||
initialDiagnostics,
|
||||
forceResourceProbes: options.forceResourceProbes === true,
|
||||
appServerMetadataSnapshot: options.appServerMetadataSnapshot === true,
|
||||
|
|
@ -62,7 +63,7 @@ async function refreshServerDiagnostics(
|
|||
...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}),
|
||||
};
|
||||
const snapshot = await host.diagnosticsTransport.readServerDiagnostics(request);
|
||||
if (!snapshot || !isCurrent() || host.stateStore.getState().activeThread.id !== activeThreadId) return false;
|
||||
if (!snapshot || !isCurrent() || activeThreadId(host.stateStore.getState()) !== threadId) return false;
|
||||
|
||||
let diagnostics = currentPanelDiagnostics(host);
|
||||
for (const probe of snapshot.resourceProbes) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
|
||||
type PendingRuntimeSettingsPatch,
|
||||
} from "../../domain/runtime/thread-settings-patch";
|
||||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { RuntimeSettingsTransport } from "./settings-transport";
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Pro
|
|||
}
|
||||
|
||||
async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<RuntimeSettingsCommitResult> {
|
||||
const threadId = state(host).activeThread.id;
|
||||
const threadId = activeThreadId(state(host));
|
||||
if (!threadId) return { ok: true, collaborationModeApplied: true };
|
||||
|
||||
const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host);
|
||||
|
|
@ -94,14 +94,14 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr
|
|||
if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) {
|
||||
return { ok: false, collaborationModeApplied: false };
|
||||
}
|
||||
if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false };
|
||||
if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false };
|
||||
if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) {
|
||||
return { ok: false, collaborationModeApplied: false };
|
||||
}
|
||||
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
|
||||
return { ok: true, collaborationModeApplied };
|
||||
} catch (error) {
|
||||
if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false };
|
||||
if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false };
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return { ok: false, collaborationModeApplied: false };
|
||||
}
|
||||
|
|
@ -158,13 +158,13 @@ async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost):
|
|||
}
|
||||
|
||||
function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
|
||||
if (state(host).activeThread.lifetime?.kind !== "ephemeral") return false;
|
||||
if (activeThreadState(state(host))?.lifetime?.kind !== "ephemeral") return false;
|
||||
host.addSystemMessage("Permission changes are unavailable in side chats.");
|
||||
return true;
|
||||
}
|
||||
|
||||
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
|
||||
if (state(host).activeThread.provenance?.kind !== "subagent") return false;
|
||||
if (activeThreadState(state(host))?.provenance?.kind !== "subagent") return false;
|
||||
host.addSystemMessage("Thread settings are unavailable in agent threads.");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
|
||||
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
|
||||
import { activeThreadRuntimeState, pendingRuntimeIntentState } from "../../domain/runtime/state";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import type { ChatState } from "../state/root-reducer";
|
||||
import { activeThreadState, type ChatState } from "../state/root-reducer";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
|
||||
interface RuntimeSnapshotInput {
|
||||
runtimeConfig: ChatState["connection"]["runtimeConfig"];
|
||||
activeThread: Pick<ChatState["activeThread"], "id" | "tokenUsage">;
|
||||
activeThread: { id: string | null; tokenUsage: ThreadTokenUsage | null };
|
||||
runtime: ChatState["runtime"];
|
||||
rateLimit: ChatState["connection"]["rateLimit"];
|
||||
hasThreadTurns: boolean;
|
||||
|
|
@ -33,9 +34,10 @@ export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): Runti
|
|||
}
|
||||
|
||||
export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot {
|
||||
const activeThread = activeThreadState(state);
|
||||
return runtimeSnapshotForChatSlices({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
activeThread: state.activeThread,
|
||||
activeThread: { id: activeThread?.id ?? null, tokenUsage: activeThread?.tokenUsage ?? null },
|
||||
runtime: state.runtime,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
hasThreadTurns: threadStreamItemsHaveThreadTurns(threadStreamItems(state.threadStream)),
|
||||
|
|
|
|||
|
|
@ -18,17 +18,17 @@ export type PendingSubmissionAction =
|
|||
export function pendingSubmissionMatches(
|
||||
state: {
|
||||
readonly pendingSubmission: ChatPendingSubmissionState | null;
|
||||
readonly activeThread: { readonly id: string | null };
|
||||
readonly activeThreadId: string | null;
|
||||
},
|
||||
submissionId: string,
|
||||
): boolean {
|
||||
return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThread.id;
|
||||
return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThreadId;
|
||||
}
|
||||
|
||||
export function cancellablePendingSubmissionMatches(
|
||||
state: {
|
||||
readonly pendingSubmission: ChatPendingSubmissionState | null;
|
||||
readonly activeThread: { readonly id: string | null };
|
||||
readonly activeThreadId: string | null;
|
||||
},
|
||||
submissionId: string,
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ interface ChatThreadListState {
|
|||
}
|
||||
|
||||
export interface ChatActiveThreadState {
|
||||
readonly id: string | null;
|
||||
readonly id: string;
|
||||
readonly title?: string | null;
|
||||
readonly cwd: string | null;
|
||||
readonly goal: ThreadGoal | null;
|
||||
|
|
@ -115,9 +115,10 @@ export interface ChatActiveThreadState {
|
|||
readonly provenance: Thread["provenance"] | null;
|
||||
}
|
||||
|
||||
export type ChatPanelRestorationState =
|
||||
| { readonly kind: "none" }
|
||||
| { readonly kind: "thread"; readonly threadId: string; readonly fallbackTitle: string | null };
|
||||
type ChatPanelThreadState =
|
||||
| { readonly kind: "empty" }
|
||||
| { readonly kind: "awaiting-resume"; readonly threadId: string; readonly fallbackTitle: string | null }
|
||||
| { readonly kind: "active"; readonly thread: ChatActiveThreadState };
|
||||
|
||||
type ActiveThreadLifetime =
|
||||
| { readonly kind: "persistent" }
|
||||
|
|
@ -133,8 +134,7 @@ interface ChatComposerState {
|
|||
interface ChatStateShape {
|
||||
connection: ChatConnectionState;
|
||||
threadList: ChatThreadListState;
|
||||
activeThread: ChatActiveThreadState;
|
||||
restoration: ChatPanelRestorationState;
|
||||
panelThread: ChatPanelThreadState;
|
||||
runtime: ChatRuntimeState;
|
||||
turn: ChatTurnState;
|
||||
threadStream: ChatThreadStreamState;
|
||||
|
|
@ -265,8 +265,7 @@ export function createChatState(): ChatState {
|
|||
return {
|
||||
connection: initialConnectionState(),
|
||||
threadList: initialThreadListState(),
|
||||
activeThread: initialActiveThreadState(),
|
||||
restoration: initialPanelRestorationState(),
|
||||
panelThread: initialPanelThreadState(),
|
||||
runtime: initialChatRuntimeState(),
|
||||
turn: initialTurnState(),
|
||||
threadStream: initialThreadStreamState(),
|
||||
|
|
@ -277,6 +276,22 @@ export function createChatState(): ChatState {
|
|||
};
|
||||
}
|
||||
|
||||
export function panelThreadId(state: ChatState): string | null {
|
||||
return state.panelThread.kind === "awaiting-resume" ? state.panelThread.threadId : activeThreadId(state);
|
||||
}
|
||||
|
||||
export function activeThreadId(state: ChatState): string | null {
|
||||
return activeThreadState(state)?.id ?? null;
|
||||
}
|
||||
|
||||
export function activeThreadState(state: ChatState): DeepReadonly<ChatActiveThreadState> | null {
|
||||
return state.panelThread.kind === "active" ? state.panelThread.thread : null;
|
||||
}
|
||||
|
||||
export function awaitingResumeThreadState(state: ChatState): Extract<ChatState["panelThread"], { kind: "awaiting-resume" }> | null {
|
||||
return state.panelThread.kind === "awaiting-resume" ? state.panelThread : null;
|
||||
}
|
||||
|
||||
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
switch (action.type) {
|
||||
case "connection/scoped-cleared":
|
||||
|
|
@ -398,16 +413,18 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
|||
...state.threadList,
|
||||
listedThreads: action.listedThreads ?? state.threadList.listedThreads,
|
||||
},
|
||||
activeThread: {
|
||||
id: action.thread.id,
|
||||
title: (action.thread.name ?? action.thread.preview) || null,
|
||||
cwd: action.cwd,
|
||||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: action.lifetime ?? { kind: "persistent" },
|
||||
provenance: action.thread.provenance,
|
||||
panelThread: {
|
||||
kind: "active",
|
||||
thread: {
|
||||
id: action.thread.id,
|
||||
title: (action.thread.name ?? action.thread.preview) || null,
|
||||
cwd: action.cwd,
|
||||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: action.lifetime ?? { kind: "persistent" },
|
||||
provenance: action.thread.provenance,
|
||||
},
|
||||
},
|
||||
restoration: initialPanelRestorationState(),
|
||||
runtime: {
|
||||
...runtimeBase,
|
||||
active: {
|
||||
|
|
@ -438,10 +455,12 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
|||
}
|
||||
|
||||
function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: ActiveThreadSettingsAppliedAction): ChatState {
|
||||
const activeThread = activeThreadState(state);
|
||||
if (!activeThread) return state;
|
||||
return patchChatState(state, {
|
||||
activeThread: {
|
||||
...state.activeThread,
|
||||
cwd: action.cwd,
|
||||
panelThread: {
|
||||
kind: "active",
|
||||
thread: { ...activeThread, cwd: action.cwd },
|
||||
},
|
||||
runtime: {
|
||||
...state.runtime,
|
||||
|
|
@ -468,12 +487,11 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A
|
|||
}
|
||||
|
||||
function reduceActiveThreadGoalSetTransition(state: ChatState, goal: ThreadGoal | null): ChatState {
|
||||
const activeThread = activeThreadState(state);
|
||||
if (!activeThread) return state;
|
||||
return patchChatState(state, {
|
||||
activeThread: {
|
||||
...state.activeThread,
|
||||
goal,
|
||||
},
|
||||
ui: maybeClearGoalObjectiveExpansion(state.ui, state.activeThread.goal, goal),
|
||||
panelThread: { kind: "active", thread: { ...activeThread, goal } },
|
||||
ui: maybeClearGoalObjectiveExpansion(state.ui, activeThread.goal, goal),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -481,13 +499,13 @@ function reduceRestoredThreadAppliedTransition(state: ChatState, threadId: strin
|
|||
const cleared = clearThreadScopedState(state);
|
||||
return patchChatState(cleared, {
|
||||
connection: { ...cleared.connection, statusText: "Thread ready to resume." },
|
||||
restoration: { kind: "thread", threadId, fallbackTitle },
|
||||
panelThread: createAwaitingResumeThreadState(threadId, fallbackTitle),
|
||||
});
|
||||
}
|
||||
|
||||
function reduceRestoredThreadRenamedTransition(state: ChatState, threadId: string, name: string | null): ChatState {
|
||||
if (state.restoration.kind !== "thread" || state.restoration.threadId !== threadId) return state;
|
||||
return patchChatState(state, { restoration: { ...state.restoration, fallbackTitle: name } });
|
||||
if (state.panelThread.kind !== "awaiting-resume" || state.panelThread.threadId !== threadId) return state;
|
||||
return patchChatState(state, { panelThread: { ...state.panelThread, fallbackTitle: name } });
|
||||
}
|
||||
|
||||
function reduceViewStateClearedTransition(state: ChatState): ChatState {
|
||||
|
|
@ -497,9 +515,12 @@ function reduceViewStateClearedTransition(state: ChatState): ChatState {
|
|||
|
||||
function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction): ChatState {
|
||||
const lifecycle = transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "started", turnId: action.turnId });
|
||||
if (lifecycle === state.turn.lifecycle) return state;
|
||||
const activeThread =
|
||||
activeThreadState(state) ?? (state.turn.lifecycle.kind === "starting" ? createActiveThreadState(action.threadId) : null);
|
||||
if (!activeThread || activeThread.id !== action.threadId) return state;
|
||||
return patchChatState(state, {
|
||||
activeThread: { ...state.activeThread, id: action.threadId },
|
||||
restoration: initialPanelRestorationState(),
|
||||
panelThread: { kind: "active", thread: activeThread },
|
||||
turn: { lifecycle },
|
||||
connection: { ...state.connection, statusText: STATUS_TURN_RUNNING },
|
||||
threadStream: action.items
|
||||
|
|
@ -595,8 +616,7 @@ function clearTurnScopedState(state: ChatState): ChatState {
|
|||
function clearThreadScopedState(state: ChatState): ChatState {
|
||||
return clearTurnScopedState(
|
||||
patchChatState(state, {
|
||||
activeThread: initialActiveThreadState(),
|
||||
restoration: initialPanelRestorationState(),
|
||||
panelThread: initialPanelThreadState(),
|
||||
runtime: initialChatRuntimeState(),
|
||||
threadStream: initialThreadStreamState(),
|
||||
pendingSubmission: null,
|
||||
|
|
@ -608,7 +628,7 @@ function clearThreadScopedState(state: ChatState): ChatState {
|
|||
|
||||
function clearConnectionScopedState(state: ChatState): ChatState {
|
||||
return patchChatState(clearTurnScopedState(state), {
|
||||
activeThread: initialActiveThreadState(),
|
||||
panelThread: state.panelThread.kind === "awaiting-resume" ? state.panelThread : initialPanelThreadState(),
|
||||
runtime: initialChatRuntimeState(),
|
||||
connection: {
|
||||
...state.connection,
|
||||
|
|
@ -642,8 +662,7 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState
|
|||
return patchChatState(state, {
|
||||
connection: reduceConnectionSlice(state.connection, action),
|
||||
threadList: reduceThreadListSlice(state.threadList, action),
|
||||
activeThread: reduceActiveThreadSlice(state.activeThread, action),
|
||||
restoration: state.restoration,
|
||||
panelThread: reducePanelThreadSlice(state.panelThread, action),
|
||||
runtime: reduceRuntimeSlice(state.runtime, action),
|
||||
turn: state.turn,
|
||||
pendingSubmission: state.pendingSubmission,
|
||||
|
|
@ -679,12 +698,13 @@ function reduceThreadListSlice(state: ChatThreadListState, action: ChatSliceActi
|
|||
return patchObject(state, { listedThreads: action.threads });
|
||||
}
|
||||
|
||||
function reduceActiveThreadSlice(state: ChatActiveThreadState, action: ChatSliceAction): ChatActiveThreadState {
|
||||
function reducePanelThreadSlice(state: ChatPanelThreadState, action: ChatSliceAction): ChatPanelThreadState {
|
||||
if (state.kind !== "active") return state;
|
||||
switch (action.type) {
|
||||
case "active-thread/cwd-set":
|
||||
return patchObject(state, { cwd: action.cwd });
|
||||
return patchObject(state, { thread: patchObject(state.thread, { cwd: action.cwd }) });
|
||||
case "active-thread/token-usage-set":
|
||||
return patchObject(state, { tokenUsage: action.tokenUsage });
|
||||
return patchObject(state, { thread: patchObject(state.thread, { tokenUsage: action.tokenUsage }) });
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -769,9 +789,22 @@ function initialThreadListState(): ChatThreadListState {
|
|||
return { listedThreads: [] };
|
||||
}
|
||||
|
||||
function initialActiveThreadState(): ChatActiveThreadState {
|
||||
function initialPanelThreadState(): ChatPanelThreadState {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
|
||||
function createAwaitingResumeThreadState(threadId: string, fallbackTitle: string | null): ChatPanelThreadState {
|
||||
return {
|
||||
id: null,
|
||||
kind: "awaiting-resume",
|
||||
threadId,
|
||||
fallbackTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function createActiveThreadState(id: string): ChatActiveThreadState {
|
||||
return {
|
||||
id,
|
||||
title: null,
|
||||
cwd: null,
|
||||
goal: null,
|
||||
tokenUsage: null,
|
||||
|
|
@ -780,10 +813,6 @@ function initialActiveThreadState(): ChatActiveThreadState {
|
|||
};
|
||||
}
|
||||
|
||||
function initialPanelRestorationState(): ChatPanelRestorationState {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
function initialTurnState(): ChatTurnState {
|
||||
return initialChatTurnState();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ function cloneChatState(state: ChatState): ChatState {
|
|||
threadList: {
|
||||
listedThreads: [...state.threadList.listedThreads],
|
||||
},
|
||||
activeThread: { ...state.activeThread },
|
||||
restoration: { ...state.restoration },
|
||||
panelThread:
|
||||
state.panelThread.kind === "active" ? { kind: "active", thread: { ...state.panelThread.thread } } : { ...state.panelThread },
|
||||
runtime: { ...state.runtime },
|
||||
turn: { lifecycle: state.turn.lifecycle },
|
||||
threadStream: cloneThreadStreamState(state.threadStream),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { activeThreadId, awaitingResumeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
||||
export interface ActiveThreadIdentitySyncHost {
|
||||
|
|
@ -36,14 +37,16 @@ function clearActiveThreadIdentity(host: ActiveThreadIdentitySyncHost): void {
|
|||
|
||||
function applyThreadArchiveToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string): void {
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId && !(state.restoration.kind === "thread" && state.restoration.threadId === threadId)) return;
|
||||
if (activeThreadId(state) !== threadId && awaitingResumeThreadState(state)?.threadId !== threadId) {
|
||||
return;
|
||||
}
|
||||
clearActiveThreadIdentity(host);
|
||||
}
|
||||
|
||||
function applyThreadRenameToActiveIdentity(host: ActiveThreadIdentitySyncHost, threadId: string, name: string | null): void {
|
||||
const state = host.stateStore.getState();
|
||||
const restoredThreadChanged = state.restoration.kind === "thread" && state.restoration.threadId === threadId;
|
||||
const activeThreadChanged = state.activeThread.id === threadId;
|
||||
const restoredThreadChanged = awaitingResumeThreadState(state)?.threadId === threadId;
|
||||
const activeThreadChanged = activeThreadId(state) === threadId;
|
||||
if (!restoredThreadChanged && !activeThreadChanged) return;
|
||||
if (restoredThreadChanged) host.stateStore.dispatch({ type: "panel/restored-thread-renamed", threadId, name });
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ephemeralThreadActivatedAction } from "../state/actions";
|
||||
import { activeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { activeTurnId, chatTurnBusy } from "../turns/turn-state";
|
||||
import type { EphemeralThreadTransport } from "./ephemeral-thread-transport";
|
||||
|
|
@ -30,8 +31,8 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
|||
let openGeneration = 0;
|
||||
const cleanupRequiredThreadIds = new Set<string>();
|
||||
const unsubscribeActiveEphemeralThread = async (): Promise<boolean> => {
|
||||
const active = host.stateStore.getState().activeThread;
|
||||
if (active.id && active.lifetime?.kind === "ephemeral") {
|
||||
const active = activeThreadState(host.stateStore.getState());
|
||||
if (active?.lifetime?.kind === "ephemeral") {
|
||||
return host.transport.unsubscribeEphemeralThread(active.id);
|
||||
}
|
||||
return true;
|
||||
|
|
@ -70,7 +71,7 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
|||
|
||||
async prepareForPersistentNavigation(): Promise<boolean> {
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.lifetime?.kind !== "ephemeral") return true;
|
||||
if (activeThreadState(state)?.lifetime?.kind !== "ephemeral") return true;
|
||||
if (chatTurnBusy(state)) {
|
||||
host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
return false;
|
||||
|
|
@ -93,7 +94,8 @@ export function createEphemeralThreadLifecycle(host: EphemeralThreadLifecycleHos
|
|||
disposed = true;
|
||||
openGeneration += 1;
|
||||
const state = host.stateStore.getState();
|
||||
const threadId = state.activeThread.lifetime?.kind === "ephemeral" ? state.activeThread.id : null;
|
||||
const activeThread = activeThreadState(state);
|
||||
const threadId = activeThread?.lifetime?.kind === "ephemeral" ? activeThread.id : null;
|
||||
const turnId = activeTurnId(state);
|
||||
if (threadId && turnId) {
|
||||
await settleWithin(host.interruptTurn(threadId, turnId), EPHEMERAL_INTERRUPT_DISPOSE_TIMEOUT_MS);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../..
|
|||
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
|
||||
import type { GoalThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import type { LocalIdSource } from "../local-id-source";
|
||||
import { activeThreadId, activeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport";
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoa
|
|||
|
||||
export function createGoalActions(host: GoalActionsHost): GoalActions {
|
||||
return {
|
||||
activeGoal: () => host.stateStore.getState().activeThread.goal,
|
||||
activeGoal: () => activeThreadState(host.stateStore.getState())?.goal ?? null,
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
|
||||
saveObjective: (objective, tokenBudget) => saveObjective(host, objective, tokenBudget),
|
||||
setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget),
|
||||
|
|
@ -102,7 +103,7 @@ async function setNormalizedObjective(
|
|||
objective: NormalizedGoalObjective,
|
||||
tokenBudget: number | null,
|
||||
): Promise<boolean> {
|
||||
const current = host.stateStore.getState().activeThread.goal;
|
||||
const current = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
||||
const isNewGoal = current === null;
|
||||
const applied = await setGoal(host, threadId, {
|
||||
objective,
|
||||
|
|
@ -116,7 +117,7 @@ async function setNormalizedObjective(
|
|||
}
|
||||
|
||||
async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise<boolean> {
|
||||
const plan = planGoalObjectiveSave(host.stateStore.getState().activeThread.id, objective, tokenBudget);
|
||||
const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget);
|
||||
switch (plan.kind) {
|
||||
case "reject":
|
||||
host.addSystemMessage(plan.message);
|
||||
|
|
@ -161,8 +162,9 @@ function applyGoalIfActive(
|
|||
options: { reportChange: boolean },
|
||||
): boolean {
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId) return false;
|
||||
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), state.activeThread.goal, goal) : null;
|
||||
const activeThread = activeThreadState(state);
|
||||
if (!activeThread || activeThread.id !== threadId) return false;
|
||||
const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null;
|
||||
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
|
||||
if (item) host.addGoalEvent(item);
|
||||
host.refreshLiveState();
|
||||
|
|
@ -171,7 +173,7 @@ function applyGoalIfActive(
|
|||
|
||||
function startEditingCurrent(host: GoalActionsHost): void {
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const goal = host.stateStore.getState().activeThread.goal;
|
||||
const goal = activeThreadState(host.stateStore.getState())?.goal ?? null;
|
||||
startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null);
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +218,7 @@ async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, ob
|
|||
}
|
||||
|
||||
function addThreadScopedSystemMessage(host: ThreadGoalSyncHost, threadId: string, text: string): void {
|
||||
if (host.stateStore.getState().activeThread.id !== threadId) return;
|
||||
if (activeThreadId(host.stateStore.getState()) !== threadId) return;
|
||||
host.addSystemMessage(text);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import type { ThreadHistoryPage, ThreadHistoryTransport } from "./thread-loading-transport";
|
||||
|
|
@ -36,7 +36,7 @@ export class HistoryController {
|
|||
this.dispatch({ type: "thread-stream/history-loading-set", loading: false });
|
||||
}
|
||||
|
||||
async loadLatest(threadId = this.state.activeThread.id): Promise<void> {
|
||||
async loadLatest(threadId = activeThreadId(this.state)): Promise<void> {
|
||||
if (!threadId) return;
|
||||
const load = this.startLoading(threadId, "latest");
|
||||
try {
|
||||
|
|
@ -53,7 +53,7 @@ export class HistoryController {
|
|||
}
|
||||
|
||||
applyLatestPage(threadId: string, response: ThreadHistoryPage): boolean {
|
||||
if (this.state.activeThread.id !== threadId) return false;
|
||||
if (activeThreadId(this.state) !== threadId) return false;
|
||||
this.host.setThreadTurnPresence(response.hadTurns);
|
||||
this.host.showLatestPageAtBottom();
|
||||
this.dispatch({
|
||||
|
|
@ -66,8 +66,8 @@ export class HistoryController {
|
|||
|
||||
async loadOlder(): Promise<void> {
|
||||
const state = this.state;
|
||||
if (!state.activeThread.id || !state.threadStream.historyCursor || state.threadStream.loadingHistory) return;
|
||||
const threadId = state.activeThread.id;
|
||||
const threadId = activeThreadId(state);
|
||||
if (!threadId || !state.threadStream.historyCursor || state.threadStream.loadingHistory) return;
|
||||
const cursor = state.threadStream.historyCursor;
|
||||
const load = this.startLoading(threadId, "older");
|
||||
try {
|
||||
|
|
@ -105,7 +105,7 @@ export class HistoryController {
|
|||
}
|
||||
|
||||
private isStale(load: ActiveThreadHistoryLoad): boolean {
|
||||
return this.lifecycle !== load || this.state.activeThread.id !== load.threadId;
|
||||
return this.lifecycle !== load || activeThreadId(this.state) !== load.threadId;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { threadRenameDraftTitle } from "../../../../domain/threads/title";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
||||
|
|
@ -113,7 +113,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
}
|
||||
|
||||
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
|
||||
return state.activeThread.id === threadId ? firstThreadTitleContextFromThreadStreamItems(threadStreamItems(state.threadStream)) : null;
|
||||
return activeThreadId(state) === threadId ? firstThreadTitleContextFromThreadStreamItems(threadStreamItems(state.threadStream)) : null;
|
||||
}
|
||||
|
||||
function renameState(host: ThreadRenameEditorActionsHost): ChatRenameUiState {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { awaitingResumeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
||||
export interface RestorationControllerHost {
|
||||
|
|
@ -16,8 +17,8 @@ export class RestorationController {
|
|||
}
|
||||
|
||||
async ensureLoaded(loadThread: RestoredThreadLoader): Promise<boolean> {
|
||||
const restoredThread = this.host.stateStore.getState().restoration;
|
||||
if (restoredThread.kind !== "thread") return true;
|
||||
const restoredThread = awaitingResumeThreadState(this.host.stateStore.getState());
|
||||
if (!restoredThread) return true;
|
||||
if (this.loading?.threadId === restoredThread.threadId) {
|
||||
await this.loading.promise;
|
||||
return this.restorationLoaded();
|
||||
|
|
@ -35,11 +36,10 @@ export class RestorationController {
|
|||
}
|
||||
|
||||
isPending(threadId: string): boolean {
|
||||
const restoration = this.host.stateStore.getState().restoration;
|
||||
return restoration.kind === "thread" && restoration.threadId === threadId;
|
||||
return awaitingResumeThreadState(this.host.stateStore.getState())?.threadId === threadId;
|
||||
}
|
||||
|
||||
private restorationLoaded(): boolean {
|
||||
return this.host.stateStore.getState().restoration.kind === "none";
|
||||
return awaitingResumeThreadState(this.host.stateStore.getState()) === null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
|
||||
import { resumedThreadAction } from "../state/actions";
|
||||
import { activeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamIsEmpty } from "../state/thread-stream";
|
||||
import type { HistoryController } from "./history-controller";
|
||||
|
|
@ -82,7 +83,8 @@ function recoverResumedThreadTokenUsage(host: ResumeActionsHost, threadId: strin
|
|||
.then((tokenUsage) => {
|
||||
if (!tokenUsage || isStaleResume(host, resume)) return;
|
||||
const state = host.stateStore.getState();
|
||||
if (state.activeThread.id !== threadId || state.activeThread.tokenUsage !== null) return;
|
||||
const activeThread = activeThreadState(state);
|
||||
if (!activeThread || activeThread.id !== threadId || activeThread.tokenUsage !== null) return;
|
||||
host.stateStore.dispatch({ type: "active-thread/token-usage-set", tokenUsage });
|
||||
host.refreshLiveState();
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model";
|
||||
import { activeThreadRuntimeState } from "../../domain/runtime/state";
|
||||
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
|
||||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
|
||||
import { chatTurnBusy } from "../turns/turn-state";
|
||||
|
|
@ -60,7 +60,7 @@ export function createThreadManagementActions(host: ThreadManagementActionsHost)
|
|||
}
|
||||
|
||||
async function compactActiveThread(host: ThreadManagementActionsHost): Promise<void> {
|
||||
const threadId = threadManagementState(host).activeThread.id;
|
||||
const threadId = activeThreadId(threadManagementState(host));
|
||||
if (!threadId) {
|
||||
host.addSystemMessage("No active thread to compact.");
|
||||
return;
|
||||
|
|
@ -108,12 +108,12 @@ async function forkThreadFromTurn(
|
|||
turnId: string | null,
|
||||
archiveSource: boolean,
|
||||
): Promise<void> {
|
||||
const activeThread = threadManagementState(host).activeThread;
|
||||
if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
const activeThread = activeThreadState(threadManagementState(host));
|
||||
if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
host.addSystemMessage("Side chats cannot be forked.");
|
||||
return;
|
||||
}
|
||||
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
if (activeThread?.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
host.addSystemMessage("Agent threads cannot be forked.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -177,12 +177,12 @@ async function renameThread(host: ThreadManagementActionsHost, threadId: string,
|
|||
}
|
||||
|
||||
async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
|
||||
const activeThread = threadManagementState(host).activeThread;
|
||||
if (activeThread.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
const activeThread = activeThreadState(threadManagementState(host));
|
||||
if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") {
|
||||
host.addSystemMessage("Side chats cannot be rolled back.");
|
||||
return;
|
||||
}
|
||||
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
if (activeThread?.id === threadId && activeThread.provenance?.kind === "subagent") {
|
||||
host.addSystemMessage("Agent threads cannot be rolled back.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -240,17 +240,17 @@ function threadManagementDispatch(host: ThreadManagementActionsHost, action: Cha
|
|||
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
|
||||
return {
|
||||
targetThreadId,
|
||||
initialActiveThreadId: threadManagementState(host).activeThread.id,
|
||||
initialActiveThreadId: activeThreadId(threadManagementState(host)),
|
||||
initialTurnLifecycle: threadManagementState(host).turn.lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
|
||||
const state = threadManagementState(host);
|
||||
return state.activeThread.id === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle;
|
||||
return activeThreadId(state) === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle;
|
||||
}
|
||||
|
||||
function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
|
||||
if (!scope.initialActiveThreadId) return true;
|
||||
return scope.initialActiveThreadId === scope.targetThreadId && threadManagementState(host).activeThread.id === scope.targetThreadId;
|
||||
return scope.initialActiveThreadId === scope.targetThreadId && activeThreadId(threadManagementState(host)) === scope.targetThreadId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { activeThreadState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { chatTurnBusy } from "../turns/turn-state";
|
||||
import type { ActiveThreadIdentitySync } from "./active-thread-identity-sync";
|
||||
|
|
@ -36,7 +37,7 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
|
|||
return {
|
||||
async startNewThread(): Promise<void> {
|
||||
const state = host.stateStore.getState();
|
||||
if (chatTurnBusy(state) && state.activeThread.provenance?.kind !== "subagent") return;
|
||||
if (chatTurnBusy(state) && activeThreadState(state)?.provenance?.kind !== "subagent") return;
|
||||
if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return;
|
||||
|
||||
host.identity.clearActiveThreadIdentity();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
|
|||
import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
|
||||
import { resumedThreadAction } from "../state/actions";
|
||||
import { pendingSubmissionMatches } from "../state/pending-submission";
|
||||
import type { ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { ThreadStartTransport } from "./thread-start-transport";
|
||||
|
||||
|
|
@ -46,7 +46,14 @@ async function startThread(
|
|||
permissions: permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig),
|
||||
});
|
||||
if (!activation) return null;
|
||||
if (options.preservePendingSubmissionId && !pendingSubmissionMatches(host.stateStore.getState(), options.preservePendingSubmissionId)) {
|
||||
const current = host.stateStore.getState();
|
||||
if (
|
||||
options.preservePendingSubmissionId &&
|
||||
!pendingSubmissionMatches(
|
||||
{ pendingSubmission: current.pendingSubmission, activeThreadId: activeThreadId(current) },
|
||||
options.preservePendingSubmissionId,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +67,7 @@ async function startThread(
|
|||
const action = resumedThreadAction({
|
||||
response: patchedActivation,
|
||||
listedThreads: state.threadList.listedThreads,
|
||||
preserveRequestedRuntimeSettings: requestState.activeThread.id === null,
|
||||
preserveRequestedRuntimeSettings: activeThreadId(requestState) === null,
|
||||
...(options.preservePendingSubmissionId ? { preservePendingSubmissionId: options.preservePendingSubmissionId } : {}),
|
||||
});
|
||||
host.stateStore.dispatch(action);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatState } from "../state/root-reducer";
|
||||
import { chatTurnBusy } from "../turns/turn-state";
|
||||
|
||||
export function canSwitchToThread(state: ChatState, threadId: string): boolean {
|
||||
return !chatTurnBusy(state) || threadId === state.activeThread.id;
|
||||
return !chatTurnBusy(state) || threadId === activeThreadId(state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,11 @@ function beginPendingWebSubmission(
|
|||
}
|
||||
|
||||
function pendingWebSubmissionIsCurrent(host: ComposerSubmitActionsHost, submissionId: string): boolean {
|
||||
return cancellablePendingSubmissionMatches(host.stateStore.getState(), submissionId);
|
||||
const state = host.stateStore.getState();
|
||||
return cancellablePendingSubmissionMatches(
|
||||
{ pendingSubmission: state.pendingSubmission, activeThreadId: submissionStateSnapshot(state).activeThreadId },
|
||||
submissionId,
|
||||
);
|
||||
}
|
||||
|
||||
function cancelPendingWebSubmission(host: ComposerSubmitActionsHost, id: string): void {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ChatRuntimeState } from "../../domain/runtime/state";
|
||||
import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/thread-stream/selectors";
|
||||
import type { ChatActiveThreadState } from "../state/root-reducer";
|
||||
import { activeThreadId, activeThreadState, type ChatActiveThreadState, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { type ChatThreadStreamState, threadStreamItems } from "../state/thread-stream";
|
||||
import { type ChatTurnState, chatTurnBusy } from "./turn-state";
|
||||
|
|
@ -14,15 +14,27 @@ export interface PlanImplementationHost {
|
|||
requestDefaultCollaborationModeForNextTurn(): void;
|
||||
}
|
||||
|
||||
export function implementPlanTargetFromState(state: {
|
||||
activeThread: Pick<ChatActiveThreadState, "id"> & { provenance?: ChatActiveThreadState["provenance"] };
|
||||
interface PlanImplementationState {
|
||||
activeThread: Pick<ChatActiveThreadState, "id" | "provenance"> | null;
|
||||
turn: ChatTurnState;
|
||||
runtime: { pending: Pick<ChatRuntimeState["pending"], "collaborationMode"> };
|
||||
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
|
||||
}): PlanImplementationTarget | null {
|
||||
}
|
||||
|
||||
export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
|
||||
return implementPlanTarget({
|
||||
activeThread: activeThreadState(state),
|
||||
turn: state.turn,
|
||||
runtime: state.runtime,
|
||||
threadStream: state.threadStream,
|
||||
});
|
||||
}
|
||||
|
||||
export function implementPlanTarget(state: PlanImplementationState): PlanImplementationTarget | null {
|
||||
const { activeThread } = state;
|
||||
if (
|
||||
!state.activeThread.id ||
|
||||
state.activeThread.provenance?.kind === "subagent" ||
|
||||
!activeThread ||
|
||||
activeThread.provenance?.kind === "subagent" ||
|
||||
chatTurnBusy(state) ||
|
||||
state.runtime.pending.collaborationMode.kind !== "set" ||
|
||||
state.runtime.pending.collaborationMode.value !== "plan"
|
||||
|
|
@ -34,7 +46,7 @@ export function implementPlanTargetFromState(state: {
|
|||
|
||||
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
|
||||
if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId) return;
|
||||
if (!(await host.ensureConnected()) || !host.stateStore.getState().activeThread.id) return;
|
||||
if (!(await host.ensureConnected()) || !activeThreadId(host.stateStore.getState())) return;
|
||||
|
||||
host.requestDefaultCollaborationModeForNextTurn();
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import type { ChatState } from "../state/root-reducer";
|
||||
import { activeThreadState, type ChatState } from "../state/root-reducer";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state";
|
||||
|
||||
|
|
@ -16,10 +16,11 @@ export interface SubmissionStateSnapshot {
|
|||
}
|
||||
|
||||
export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapshot {
|
||||
const activeThread = activeThreadState(state);
|
||||
return {
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
|
||||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
|
||||
activeTurnId: activeTurnId(state),
|
||||
busy: chatTurnBusy(state),
|
||||
listedThreads: state.threadList.listedThreads,
|
||||
|
|
|
|||
|
|
@ -326,7 +326,12 @@ function isCurrentTurn(host: TurnSubmissionActionsHost, threadId: string, turnId
|
|||
}
|
||||
|
||||
function pendingRequestIsCurrent(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
|
||||
return request.pendingSubmissionId ? pendingSubmissionMatches(host.stateStore.getState(), request.pendingSubmissionId) : true;
|
||||
if (!request.pendingSubmissionId) return true;
|
||||
const state = host.stateStore.getState();
|
||||
return pendingSubmissionMatches(
|
||||
{ pendingSubmission: state.pendingSubmission, activeThreadId: submissionStateSnapshot(state).activeThreadId },
|
||||
request.pendingSubmissionId,
|
||||
);
|
||||
}
|
||||
|
||||
function commitPendingRequest(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
|
||||
import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions";
|
||||
import { activeThreadId, activeThreadState } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
import type { HistoryController } from "../../application/threads/history-controller";
|
||||
import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions";
|
||||
|
|
@ -68,12 +69,12 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
rename,
|
||||
navigation,
|
||||
openSideChat: () => {
|
||||
const activeThreadId = stateStore.getState().activeThread.id;
|
||||
if (!activeThreadId) return;
|
||||
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId);
|
||||
void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null);
|
||||
const threadId = activeThreadId(stateStore.getState());
|
||||
if (!threadId) return;
|
||||
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||
void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null);
|
||||
},
|
||||
activeThreadChatActionsDisabled: () => stateStore.getState().activeThread.provenance?.kind === "subagent",
|
||||
activeThreadChatActionsDisabled: () => activeThreadState(stateStore.getState())?.provenance?.kind === "subagent",
|
||||
});
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
connection: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { AppServerClient } from "../../../app-server/connection/client";
|
|||
import { type AppServerQueryContext, appServerQueryContextMatches, appServerQueryContextRawEquals } from "../../../app-server/query/keys";
|
||||
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
|
||||
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
|
||||
import type { ChatPanelRestorationState, ChatState } from "../application/state/root-reducer";
|
||||
import {
|
||||
activeThreadId,
|
||||
activeThreadState,
|
||||
awaitingResumeThreadState,
|
||||
type ChatState,
|
||||
panelThreadId,
|
||||
} from "../application/state/root-reducer";
|
||||
import { type ChatStateStore, createChatStateStore } from "../application/state/store";
|
||||
import { ChatResumeWorkTracker } from "../application/threads/resume-work";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom";
|
||||
|
|
@ -33,14 +39,14 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
}
|
||||
|
||||
displayTitle(): string {
|
||||
if (this.state.activeThread.lifetime?.kind === "ephemeral") {
|
||||
if (activeThreadState(this.state)?.lifetime?.kind === "ephemeral") {
|
||||
return "Side chat";
|
||||
}
|
||||
return threadWindowTitle(this.panelThreadId(), this.state.threadList.listedThreads, this.restoredThreadTitle());
|
||||
}
|
||||
|
||||
persistedState(): Record<string, unknown> {
|
||||
const lifetime = this.state.activeThread.lifetime;
|
||||
const lifetime = activeThreadState(this.state)?.lifetime;
|
||||
if (lifetime?.kind === "ephemeral") {
|
||||
return {
|
||||
version: 2,
|
||||
|
|
@ -80,7 +86,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
const nextContext = this.currentAppServerContext();
|
||||
if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) {
|
||||
this.observedAppServerContext = nextContext;
|
||||
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(this.state.activeThread.id);
|
||||
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(activeThreadId(this.state));
|
||||
void this.reconnectAfterAppServerContextChange(replacement);
|
||||
this.runtime.runtime.sharedState.applyCached();
|
||||
}
|
||||
|
|
@ -88,8 +94,8 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
}
|
||||
|
||||
prepareAppServerContextChange(): void {
|
||||
const activeThreadId = this.pendingAppServerContextReplacement?.activeThreadId ?? this.state.activeThread.id;
|
||||
this.captureAppServerContextReplacement(activeThreadId);
|
||||
const threadId = this.pendingAppServerContextReplacement?.activeThreadId ?? activeThreadId(this.state);
|
||||
this.captureAppServerContextReplacement(threadId);
|
||||
this.runtime.actions.prepareAppServerContextChange();
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +186,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
|
||||
async focusThread(threadId: string | null = null): Promise<void> {
|
||||
const restoredThread = this.restoredThread();
|
||||
const restoredThreadId = restoredThread.kind === "thread" ? restoredThread.threadId : null;
|
||||
const restoredThreadId = restoredThread?.threadId ?? null;
|
||||
if ((threadId && this.runtime.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) {
|
||||
await this.ensureRestoredThreadLoaded();
|
||||
}
|
||||
|
|
@ -282,26 +288,26 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
}
|
||||
|
||||
private activeThreadTitle(): string | null {
|
||||
const threadId = this.state.activeThread.id;
|
||||
if (!threadId) return null;
|
||||
const activeThread = activeThreadState(this.state);
|
||||
if (!activeThread) return null;
|
||||
const threadId = activeThread.id;
|
||||
const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
return thread ? threadMeaningfulTitle(thread) : (this.state.activeThread.title ?? null);
|
||||
return thread ? threadMeaningfulTitle(thread) : (activeThread.title ?? null);
|
||||
}
|
||||
|
||||
private restoredThreadTitle(): string | null {
|
||||
const restoredThread = this.restoredThread();
|
||||
if (restoredThread.kind !== "thread") return null;
|
||||
if (!restoredThread) return null;
|
||||
const listedThread = this.state.threadList.listedThreads.find((thread) => thread.id === restoredThread.threadId);
|
||||
return listedThread ? threadMeaningfulTitle(listedThread) : restoredThread.fallbackTitle;
|
||||
}
|
||||
|
||||
private restoredThread(): ChatPanelRestorationState {
|
||||
return this.state.restoration;
|
||||
private restoredThread(): ReturnType<typeof awaitingResumeThreadState> {
|
||||
return awaitingResumeThreadState(this.state);
|
||||
}
|
||||
|
||||
private panelThreadId(): string | null {
|
||||
const restoredThread = this.restoredThread();
|
||||
return restoredThread.kind === "thread" ? restoredThread.threadId : this.state.activeThread.id;
|
||||
return panelThreadId(this.state);
|
||||
}
|
||||
|
||||
private ensureRestoredThreadLoaded(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
type PreparedComposerInput,
|
||||
preparedUserInputWithWikiLinkMentionsSkillsAndContext,
|
||||
} from "../application/composer/wikilink-context";
|
||||
import type { ChatAction, ChatState } from "../application/state/root-reducer";
|
||||
import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer";
|
||||
import { syncComposerHeight } from "../ui/composer.dom";
|
||||
|
|
@ -253,9 +253,9 @@ export class ChatComposerController {
|
|||
state.connection.availableModels,
|
||||
this.options.currentModelForSuggestions(),
|
||||
{
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
|
||||
activeThreadId: activeThreadState(state)?.id ?? null,
|
||||
activeThreadEphemeral: activeThreadState(state)?.lifetime?.kind === "ephemeral",
|
||||
activeThreadSubagent: activeThreadState(state)?.provenance?.kind === "subagent",
|
||||
contextReferences: this.contextReferences(),
|
||||
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
|
|
@ -306,7 +306,7 @@ export class ChatComposerController {
|
|||
state.connection.availableModels,
|
||||
this.options.currentModelForSuggestions(),
|
||||
{
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadId: activeThreadState(state)?.id ?? null,
|
||||
contextReferences: this.contextReferences(),
|
||||
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { runtimeConfigOrDefault } from "../../../domain/runtime/config";
|
||||
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
|
||||
import type { ChatState } from "../application/state/root-reducer";
|
||||
import { activeThreadId, type ChatState } from "../application/state/root-reducer";
|
||||
import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels";
|
||||
import { resolveRuntimeControls } from "../domain/runtime/resolution";
|
||||
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
|
||||
|
|
@ -46,7 +46,7 @@ function statusDetails(input: ChatPanelRuntimeProjectionInput): ThreadStreamNoti
|
|||
const state = input.state();
|
||||
return noticeSectionsFromRows(
|
||||
buildStatusDetails({
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeThreadId: activeThreadId(state),
|
||||
snapshot: runtimeSnapshot(state),
|
||||
nowMs: input.nowMs(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals";
|
||||
import { explicitThreadName } from "../../../domain/threads/model";
|
||||
import { runtimeSnapshotForChatSlices, threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
|
||||
import type { ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatActiveThreadState, ChatState } from "../application/state/root-reducer";
|
||||
import {
|
||||
type ThreadStreamRollbackCandidate,
|
||||
threadStreamActiveItems,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
threadStreamRollbackCandidateFromItems,
|
||||
threadStreamStableItems,
|
||||
} from "../application/state/thread-stream";
|
||||
import { implementPlanTargetFromState } from "../application/turns/plan-implementation";
|
||||
import { implementPlanTarget } from "../application/turns/plan-implementation";
|
||||
import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state";
|
||||
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
|
||||
import type { ThreadStreamItem } from "../domain/thread-stream/items";
|
||||
|
|
@ -30,7 +30,8 @@ interface ChatPanelShellReadModel {
|
|||
interface ChatPanelShellSignals {
|
||||
connection: Signal<ChatState["connection"]>;
|
||||
threadList: Signal<ChatState["threadList"]>;
|
||||
activeThread: Signal<ChatState["activeThread"]>;
|
||||
panelThread: Signal<ChatState["panelThread"]>;
|
||||
activeThread: ReadonlySignal<ChatActiveThreadState | null>;
|
||||
runtime: Signal<ChatState["runtime"]>;
|
||||
turn: Signal<ChatState["turn"]>;
|
||||
threadStream: Signal<ChatState["threadStream"]>;
|
||||
|
|
@ -40,9 +41,9 @@ interface ChatPanelShellSignals {
|
|||
ui: Signal<ChatState["ui"]>;
|
||||
turnBusy: ReadonlySignal<boolean>;
|
||||
activeTurnId: ReadonlySignal<string | null>;
|
||||
activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
activeThreadCwd: ReadonlySignal<ChatState["activeThread"]["cwd"]>;
|
||||
activeThreadGoal: ReadonlySignal<ChatState["activeThread"]["goal"]>;
|
||||
activeThreadId: ReadonlySignal<string | null>;
|
||||
activeThreadCwd: ReadonlySignal<ChatActiveThreadState["cwd"]>;
|
||||
activeThreadGoal: ReadonlySignal<ChatActiveThreadState["goal"]>;
|
||||
threadStreamItems: ReadonlySignal<readonly ThreadStreamItem[]>;
|
||||
threadStreamStableItems: ReadonlySignal<readonly ThreadStreamItem[]>;
|
||||
threadStreamActiveItems: ReadonlySignal<readonly ThreadStreamItem[]>;
|
||||
|
|
@ -73,7 +74,7 @@ interface ChatPanelToolbarDiagnosticState {
|
|||
}
|
||||
|
||||
interface ChatPanelToolbarDebugState {
|
||||
readonly activeThreadId: ChatState["activeThread"]["id"];
|
||||
readonly activeThreadId: string | null;
|
||||
readonly connection: ChatPanelToolbarDebugConnectionState;
|
||||
readonly runtimeConfig: ChatState["connection"]["runtimeConfig"];
|
||||
readonly runtime: ChatState["runtime"];
|
||||
|
|
@ -82,7 +83,7 @@ interface ChatPanelToolbarDebugState {
|
|||
|
||||
export interface ChatPanelToolbarReadModel {
|
||||
readonly threads: ReadonlySignal<ChatState["threadList"]["listedThreads"]>;
|
||||
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
readonly activeThreadId: ReadonlySignal<string | null>;
|
||||
readonly activeThreadSubagent: ReadonlySignal<boolean>;
|
||||
readonly turnBusy: ReadonlySignal<boolean>;
|
||||
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
|
||||
|
|
@ -96,7 +97,7 @@ export interface ChatPanelToolbarReadModel {
|
|||
// Goal read model
|
||||
|
||||
export interface ChatPanelGoalReadModel {
|
||||
readonly goal: ReadonlySignal<ChatState["activeThread"]["goal"]>;
|
||||
readonly goal: ReadonlySignal<ChatActiveThreadState["goal"]>;
|
||||
readonly goalEditor: ReadonlySignal<ChatState["ui"]["goalEditor"]>;
|
||||
readonly goalObjectiveExpanded: ReadonlySignal<ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]>;
|
||||
}
|
||||
|
|
@ -104,8 +105,8 @@ export interface ChatPanelGoalReadModel {
|
|||
// Thread stream read model
|
||||
|
||||
export interface ChatPanelThreadStreamReadModel {
|
||||
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
readonly activeThreadCwd: ReadonlySignal<ChatState["activeThread"]["cwd"]>;
|
||||
readonly activeThreadId: ReadonlySignal<string | null>;
|
||||
readonly activeThreadCwd: ReadonlySignal<ChatActiveThreadState["cwd"]>;
|
||||
readonly activeTurnId: ReadonlySignal<string | null>;
|
||||
readonly historyCursor: ReadonlySignal<ChatState["threadStream"]["historyCursor"]>;
|
||||
readonly loadingHistory: ReadonlySignal<ChatState["threadStream"]["loadingHistory"]>;
|
||||
|
|
@ -141,7 +142,7 @@ export interface ChatPanelComposerReadModel {
|
|||
readonly draft: ReadonlySignal<ChatState["composer"]["draft"]>;
|
||||
readonly suggestions: ReadonlySignal<ChatState["composer"]["suggestions"]>;
|
||||
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
|
||||
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
|
||||
readonly activeThreadId: ReadonlySignal<string | null>;
|
||||
readonly activeThreadSubagent: ReadonlySignal<boolean>;
|
||||
readonly webSubmissionPending: ReadonlySignal<boolean>;
|
||||
readonly webSubmissionCancellable: ReadonlySignal<boolean>;
|
||||
|
|
@ -153,7 +154,8 @@ export interface ChatPanelComposerReadModel {
|
|||
export function createChatPanelShellReadModelBinding(initialState: ChatState): ChatPanelShellReadModelBinding {
|
||||
const connection = signal(initialState.connection);
|
||||
const threadList = signal(initialState.threadList);
|
||||
const activeThread = signal(initialState.activeThread);
|
||||
const panelThread = signal(initialState.panelThread);
|
||||
const activeThread = computed(() => (panelThread.value.kind === "active" ? panelThread.value.thread : null));
|
||||
const runtime = signal(initialState.runtime);
|
||||
const turn = signal(initialState.turn);
|
||||
const threadStream = signal(initialState.threadStream);
|
||||
|
|
@ -165,13 +167,14 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
|
|||
const canonicalStreamItems = computed(() => threadStreamItems(threadStream.value));
|
||||
const streamItems = computed(() => appendPendingSubmission(canonicalStreamItems.value, pendingSubmission.value));
|
||||
const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(canonicalStreamItems.value));
|
||||
const activeThreadIdSignal = computed(() => activeThread.value.id);
|
||||
const activeThreadCwd = computed(() => activeThread.value.cwd);
|
||||
const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage);
|
||||
const activeThreadGoal = computed(() => activeThread.value.goal);
|
||||
const activeThreadIdSignal = computed(() => activeThread.value?.id ?? null);
|
||||
const activeThreadCwd = computed(() => activeThread.value?.cwd ?? null);
|
||||
const activeThreadTokenUsage = computed(() => activeThread.value?.tokenUsage ?? null);
|
||||
const activeThreadGoal = computed(() => activeThread.value?.goal ?? null);
|
||||
const signals: ChatPanelShellSignals = {
|
||||
connection,
|
||||
threadList,
|
||||
panelThread,
|
||||
activeThread,
|
||||
runtime,
|
||||
turn,
|
||||
|
|
@ -197,18 +200,18 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
|
|||
: threadStreamActiveItems(threadStream.value),
|
||||
),
|
||||
threadStreamRollbackCandidate: computed(() =>
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
|
||||
turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent"
|
||||
? null
|
||||
: threadStreamRollbackCandidateFromItems(canonicalStreamItems.value),
|
||||
),
|
||||
threadStreamForkCandidates: computed(() =>
|
||||
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
|
||||
turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent"
|
||||
? []
|
||||
: forkCandidatesFromItems(canonicalStreamItems.value),
|
||||
),
|
||||
threadStreamImplementPlanTarget: computed(() =>
|
||||
implementPlanTargetFromState({
|
||||
activeThread: { id: activeThreadIdSignal.value, provenance: activeThread.value.provenance },
|
||||
implementPlanTarget({
|
||||
activeThread: activeThread.value,
|
||||
turn: turn.value,
|
||||
runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } },
|
||||
threadStream: threadStream.value,
|
||||
|
|
@ -255,7 +258,7 @@ function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState):
|
|||
batch(() => {
|
||||
if (signals.connection.value !== nextState.connection) signals.connection.value = nextState.connection;
|
||||
if (signals.threadList.value !== nextState.threadList) signals.threadList.value = nextState.threadList;
|
||||
if (signals.activeThread.value !== nextState.activeThread) signals.activeThread.value = nextState.activeThread;
|
||||
if (signals.panelThread.value !== nextState.panelThread) signals.panelThread.value = nextState.panelThread;
|
||||
if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime;
|
||||
if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn;
|
||||
if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream;
|
||||
|
|
@ -286,7 +289,7 @@ function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelT
|
|||
return {
|
||||
threads: computed(() => signals.threadList.value.listedThreads),
|
||||
activeThreadId: signals.activeThreadId,
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"),
|
||||
turnBusy: signals.turnBusy,
|
||||
runtimeSnapshot: signals.toolbarRuntimeSnapshot,
|
||||
toolbarPanel: computed(() => signals.ui.value.toolbarPanel),
|
||||
|
|
@ -356,16 +359,16 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel
|
|||
availableModels: computed(() => signals.connection.value.availableModels),
|
||||
},
|
||||
activeListedThreadName: computed(() => activeListedThreadName(signals)),
|
||||
sideChatActive: computed(() => signals.activeThread.value.lifetime?.kind === "ephemeral"),
|
||||
sideChatActive: computed(() => signals.activeThread.value?.lifetime?.kind === "ephemeral"),
|
||||
sideChatSourceTitle: computed(() => {
|
||||
const lifetime = signals.activeThread.value.lifetime;
|
||||
const lifetime = signals.activeThread.value?.lifetime;
|
||||
return lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null;
|
||||
}),
|
||||
draft: computed(() => signals.composer.value.draft),
|
||||
suggestions: computed(() => signals.composer.value.suggestions),
|
||||
selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected),
|
||||
activeThreadId: signals.activeThreadId,
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
|
||||
activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"),
|
||||
webSubmissionPending: signals.webSubmissionPending,
|
||||
webSubmissionCancellable: signals.webSubmissionCancellable,
|
||||
turnBusy: signals.turnBusy,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import {
|
|||
createChatInboundHandler,
|
||||
} from "../../../../../src/features/chat/app-server/inbound/handler";
|
||||
import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source";
|
||||
import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import {
|
||||
activeThreadState,
|
||||
type ChatAction,
|
||||
type ChatState,
|
||||
chatReducer,
|
||||
} from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state";
|
||||
import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/thread-stream/updates";
|
||||
|
|
@ -1351,7 +1356,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { threadId: "thread-active" },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/archived" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.id).toBe("thread-active");
|
||||
expect(activeThreadState(handler.currentState())?.id).toBe("thread-active");
|
||||
expect(applyThreadCatalogEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "thread-archived",
|
||||
|
|
@ -1413,7 +1418,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { thread: appServerThread("thread-other", "/workspace/other") },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/started" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.cwd).toBe("/workspace/active");
|
||||
expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active");
|
||||
expect(applyThreadCatalogEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "thread-started",
|
||||
|
|
@ -1434,7 +1439,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { thread: appServerThread("thread-active", "/workspace/active") },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/started" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.cwd).toBe("/workspace/active");
|
||||
expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active");
|
||||
expect(applyThreadCatalogEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "thread-started",
|
||||
|
|
@ -1444,7 +1449,7 @@ describe("ChatInboundHandler", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps ephemeral thread-started notifications out of the shared catalog", () => {
|
||||
it("keeps ephemeral thread-started notifications out of the shared catalog and an empty panel", () => {
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const handler = handlerForState(chatStateFixture(), { applyThreadCatalogEvent });
|
||||
|
||||
|
|
@ -1453,7 +1458,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { thread: { ...appServerThread("side", "/workspace/active"), ephemeral: true } },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/started" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.cwd).toBe("/workspace/active");
|
||||
expect(handler.currentState().panelThread).toEqual({ kind: "empty" });
|
||||
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -1775,7 +1780,7 @@ describe("ChatInboundHandler", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "thread/settings/updated" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.cwd).toBe("/workspace/active");
|
||||
expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active");
|
||||
expect(handler.currentState().runtime.active.model).toBe("gpt-5.5");
|
||||
expect(handler.currentState().runtime.active.serviceTier).toBe("fast");
|
||||
expect(handler.currentState().runtime.active.approvalsReviewer).toBe("auto_review");
|
||||
|
|
@ -1822,7 +1827,7 @@ describe("ChatInboundHandler", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "thread/settings/updated" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.cwd).toBe("/workspace/active");
|
||||
expect(activeThreadState(handler.currentState())?.cwd).toBe("/workspace/active");
|
||||
expect(handler.currentState().runtime.active.model).toBe("gpt-active");
|
||||
expect(handler.currentState().runtime.active.serviceTier).toBe("flex");
|
||||
expect(handler.currentState().runtime.active.approvalsReviewer).toBe("user");
|
||||
|
|
@ -1885,7 +1890,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { threadId: "thread-active", turnId: null, goal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.goal).toEqual(goal);
|
||||
expect(activeThreadState(handler.currentState())?.goal).toEqual(goal);
|
||||
expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "set: Finish",
|
||||
|
|
@ -1950,7 +1955,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { threadId: "thread-active" },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.goal).toBeNull();
|
||||
expect(activeThreadState(handler.currentState())?.goal).toBeNull();
|
||||
expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "cleared: Finish well",
|
||||
|
|
@ -1989,7 +1994,7 @@ describe("ChatInboundHandler", () => {
|
|||
params: { threadId: "thread-active", turnId: "turn-1", goal: completedGoal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
|
||||
expect(handler.currentState().activeThread.goal).toEqual(completedGoal);
|
||||
expect(activeThreadState(handler.currentState())?.goal).toEqual(completedGoal);
|
||||
expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1);
|
||||
expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({
|
||||
kind: "goal",
|
||||
|
|
@ -2023,7 +2028,7 @@ describe("ChatInboundHandler", () => {
|
|||
method: "thread/goal/updated",
|
||||
params: { threadId: "previous-thread", turnId: null, goal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(noActiveState.activeThread.goal).toBeNull();
|
||||
expect(activeThreadState(noActiveState)).toBeNull();
|
||||
|
||||
let otherThreadState = chatStateFixture();
|
||||
otherThreadState = chatStateWith(otherThreadState, { activeThread: { id: "thread-active" } });
|
||||
|
|
@ -2035,7 +2040,7 @@ describe("ChatInboundHandler", () => {
|
|||
method: "thread/goal/cleared",
|
||||
params: { threadId: "previous-thread" },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
|
||||
expect(expectPresent(otherThreadState.activeThread.goal).objective).toBe("Current");
|
||||
expect(expectPresent(activeThreadState(otherThreadState)?.goal).objective).toBe("Current");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
import type { RuntimeSettingsTransport } from "../../../../../src/features/chat/application/runtime/settings-transport";
|
||||
import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
|
||||
import type { ActiveThreadSettingsAppliedAction } from "../../../../../src/features/chat/application/state/actions";
|
||||
import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadId, type ChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
|
||||
import { chatStateFixture, chatStateWith } from "../../support/state";
|
||||
|
|
@ -392,7 +392,7 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false);
|
||||
|
||||
expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" });
|
||||
expect(store.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(store.getState())).toBeNull();
|
||||
expect(store.getState().runtime.active.model).toBeNull();
|
||||
expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" });
|
||||
expect(messages).toEqual([]);
|
||||
|
|
@ -413,7 +413,7 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
|
||||
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false);
|
||||
|
||||
expect(store.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(store.getState())).toBeNull();
|
||||
expect(store.getState().runtime.active.model).toBeNull();
|
||||
expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" });
|
||||
expect(messages).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
|
||||
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadState, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream";
|
||||
import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state";
|
||||
|
|
@ -87,13 +87,38 @@ describe("chatReducer", () => {
|
|||
fallbackTitle: "Restored title",
|
||||
});
|
||||
|
||||
expect(restored.restoration).toEqual({ kind: "thread", threadId: "restored-thread", fallbackTitle: "Restored title" });
|
||||
expect(restored.activeThread.id).toBeNull();
|
||||
expect(restored.panelThread).toEqual({
|
||||
kind: "awaiting-resume",
|
||||
threadId: "restored-thread",
|
||||
fallbackTitle: "Restored title",
|
||||
});
|
||||
expect(restored.connection.statusText).toBe("Thread ready to resume.");
|
||||
expectThreadScopeReset(restored, { items: [] });
|
||||
|
||||
const disconnected = chatReducer(restored, { type: "connection/scoped-cleared" });
|
||||
expect(disconnected.restoration).toEqual(restored.restoration);
|
||||
expect(disconnected.panelThread).toEqual(restored.panelThread);
|
||||
});
|
||||
|
||||
it("keeps active-only metadata out of the awaiting-resume phase", () => {
|
||||
let state = chatReducer(chatStateFixture(), {
|
||||
type: "panel/restored-thread-applied",
|
||||
threadId: "restored-thread",
|
||||
fallbackTitle: "Restored title",
|
||||
});
|
||||
const awaitingResume = state.panelThread;
|
||||
|
||||
state = chatReducer(state, { type: "active-thread/cwd-set", cwd: "/stale" });
|
||||
const usage = {
|
||||
total: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
|
||||
last: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
|
||||
modelContextWindow: 100,
|
||||
};
|
||||
state = chatReducer(state, { type: "active-thread/token-usage-set", tokenUsage: usage });
|
||||
state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("restored-thread") });
|
||||
state = chatReducer(state, { type: "turn/started", threadId: "other-thread", turnId: "stale-turn" });
|
||||
|
||||
expect(state.panelThread).toEqual(awaitingResume);
|
||||
expect(state.turn.lifecycle).toEqual({ kind: "idle" });
|
||||
});
|
||||
|
||||
it("clears active turn and thread-scoped state", () => {
|
||||
|
|
@ -117,8 +142,7 @@ describe("chatReducer", () => {
|
|||
|
||||
const next = chatReducer(pendingState, { type: "active-thread/cleared" });
|
||||
|
||||
expect(next.activeThread.id).toBeNull();
|
||||
expect(next.activeThread.goal).toBeNull();
|
||||
expect(next.panelThread).toEqual({ kind: "empty" });
|
||||
expect(next.runtime.active.model).toBeNull();
|
||||
expect(next.runtime.active.reasoningEffort).toBeNull();
|
||||
expect(next.runtime.active.serviceTier).toBeNull();
|
||||
|
|
@ -155,8 +179,7 @@ describe("chatReducer", () => {
|
|||
items: resumedItems,
|
||||
});
|
||||
|
||||
expect(next.activeThread.id).toBe("resumed-thread");
|
||||
expect(next.activeThread.goal).toBeNull();
|
||||
expect(activeThreadState(next)).toMatchObject({ id: "resumed-thread", goal: null });
|
||||
expect(next.runtime.active.collaborationMode).toBeNull();
|
||||
expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" });
|
||||
expectThreadScopeReset(next, { items: resumedItems });
|
||||
|
|
@ -188,7 +211,7 @@ describe("chatReducer", () => {
|
|||
preserveRequestedRuntimeSettings: true,
|
||||
});
|
||||
|
||||
expect(next.activeThread.id).toBe("started-thread");
|
||||
expect(activeThreadState(next)?.id).toBe("started-thread");
|
||||
expect(next.runtime.active.model).toBe("gpt-5");
|
||||
expect(next.runtime.active.reasoningEffort).toBe("medium");
|
||||
expect(next.runtime.active.serviceTier).toBe("fast");
|
||||
|
|
@ -635,7 +658,7 @@ describe("chatReducer", () => {
|
|||
});
|
||||
|
||||
it("clears expanded goal objective state when the displayed goal identity changes", () => {
|
||||
let state = chatStateFixture();
|
||||
let state = chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } });
|
||||
state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("thread") });
|
||||
state = chatReducer(state, { type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true });
|
||||
|
||||
|
|
@ -827,14 +850,14 @@ describe("chatReducer", () => {
|
|||
panelA.dispatch({ type: "active-thread/cleared" });
|
||||
|
||||
expect(panelA.getState()).toMatchObject({
|
||||
activeThread: { id: null },
|
||||
panelThread: { kind: "empty" },
|
||||
composer: { draft: "" },
|
||||
requests: { pendingUserInputs: [] },
|
||||
});
|
||||
expect(panelA.getState().requests.userInputDrafts.size).toBe(0);
|
||||
|
||||
expect(panelB.getState()).toMatchObject({
|
||||
activeThread: { id: "thread-b" },
|
||||
panelThread: { kind: "active", thread: { id: "thread-b" } },
|
||||
composer: { draft: "panel B draft" },
|
||||
requests: { pendingUserInputs: [expect.objectContaining({ requestId: 2 })] },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadId, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { createActiveThreadIdentitySync } from "../../../../../src/features/chat/application/threads/active-thread-identity-sync";
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ describe("createActiveThreadIdentitySync", () => {
|
|||
|
||||
sync.applyThreadArchiveToActiveIdentity("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBeNull();
|
||||
expect(host.invalidateThreadWork).toHaveBeenCalledOnce();
|
||||
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
|
||||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
|
|
@ -74,7 +74,7 @@ describe("createActiveThreadIdentitySync", () => {
|
|||
|
||||
sync.applyThreadArchiveToActiveIdentity("other");
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBe("active");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("active");
|
||||
expect(host.invalidateThreadWork).not.toHaveBeenCalled();
|
||||
expect(host.resetThreadTurnPresence).not.toHaveBeenCalled();
|
||||
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
||||
|
|
@ -86,9 +86,9 @@ describe("createActiveThreadIdentitySync", () => {
|
|||
|
||||
sync.applyThreadArchiveToActiveIdentity("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBeNull();
|
||||
expect(host.invalidateThreadWork).toHaveBeenCalledOnce();
|
||||
expect(stateStore.getState().restoration).toEqual({ kind: "none" });
|
||||
expect(stateStore.getState().panelThread.kind).toBe("empty");
|
||||
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
|
||||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
@ -122,7 +122,7 @@ describe("createActiveThreadIdentitySync", () => {
|
|||
|
||||
sync.applyThreadRenameToActiveIdentity("thread", "New");
|
||||
|
||||
expect(stateStore.getState().restoration).toEqual({ kind: "thread", threadId: "thread", fallbackTitle: "New" });
|
||||
expect(stateStore.getState().panelThread).toEqual({ kind: "awaiting-resume", threadId: "thread", fallbackTitle: "New" });
|
||||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation";
|
||||
import { activeThreadId, activeThreadState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { createEphemeralThreadLifecycle } from "../../../../../src/features/chat/application/threads/ephemeral-thread-lifecycle";
|
||||
import type { EphemeralThreadTransport } from "../../../../../src/features/chat/application/threads/ephemeral-thread-transport";
|
||||
|
|
@ -35,7 +35,7 @@ describe("ephemeral thread lifecycle", () => {
|
|||
await expect(lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" })).resolves.toBe(true);
|
||||
|
||||
expect(transport.forkEphemeralThread).toHaveBeenCalledWith("source");
|
||||
expect(store.getState().activeThread).toMatchObject({
|
||||
expect(activeThreadState(store.getState())).toMatchObject({
|
||||
id: "side",
|
||||
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
||||
});
|
||||
|
|
@ -58,7 +58,7 @@ describe("ephemeral thread lifecycle", () => {
|
|||
await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(true);
|
||||
|
||||
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
||||
expect(store.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(store.getState())).toBeNull();
|
||||
});
|
||||
|
||||
it("interrupts a running side turn before close cleanup", async () => {
|
||||
|
|
@ -108,7 +108,7 @@ describe("ephemeral thread lifecycle", () => {
|
|||
|
||||
await expect(opening).resolves.toBe(false);
|
||||
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
||||
expect(store.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(store.getState())).toBeNull();
|
||||
});
|
||||
|
||||
it("still unsubscribes the side chat when interrupting its running turn fails", async () => {
|
||||
|
|
@ -173,7 +173,7 @@ describe("ephemeral thread lifecycle", () => {
|
|||
|
||||
await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(false);
|
||||
|
||||
expect(store.getState().activeThread.id).toBe("side");
|
||||
expect(activeThreadId(store.getState())).toBe("side");
|
||||
expect(addSystemMessage).toHaveBeenCalledWith("Could not discard the side chat. Try again before switching threads.");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
|
||||
import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source";
|
||||
import { activeThreadId, activeThreadState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { createGoalActions } from "../../../../../src/features/chat/application/threads/goal-actions";
|
||||
import type { ThreadGoalTransport } from "../../../../../src/features/chat/application/threads/goal-transport";
|
||||
|
|
@ -28,7 +28,7 @@ describe("createGoalActions", () => {
|
|||
|
||||
await actions.syncThreadGoal("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.goal).toEqual(currentGoal);
|
||||
expect(activeThreadState(stateStore.getState())?.goal).toEqual(currentGoal);
|
||||
expect(refreshLiveState).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
@ -50,8 +50,8 @@ describe("createGoalActions", () => {
|
|||
|
||||
await actions.syncThreadGoal("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBe("thread");
|
||||
expect(stateStore.getState().activeThread.goal).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBe("thread");
|
||||
expect(activeThreadState(stateStore.getState())?.goal).toBeNull();
|
||||
expect(addSystemMessage).toHaveBeenCalledWith("Could not load thread goal: offline");
|
||||
});
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ describe("createGoalActions", () => {
|
|||
expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "updated: Updated", objective: "Updated" }));
|
||||
expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "paused: Updated", objective: "Updated" }));
|
||||
expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "cleared: Updated", objective: "Updated" }));
|
||||
expect(stateStore.getState().activeThread.goal).toBeNull();
|
||||
expect(activeThreadState(stateStore.getState())?.goal).toBeNull();
|
||||
});
|
||||
|
||||
it("does not report stale goal action failures after the active thread changes", async () => {
|
||||
|
|
@ -366,7 +366,7 @@ describe("createGoalActions", () => {
|
|||
|
||||
await actions.syncThreadGoal("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.goal).toEqual(currentGoal);
|
||||
expect(activeThreadState(stateStore.getState())?.goal).toEqual(currentGoal);
|
||||
expect(addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { ThreadTokenUsage } from "../../../../../src/domain/runtime/metrics";
|
||||
import type { Thread as PanelThread } from "../../../../../src/domain/threads/model";
|
||||
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadId, activeThreadState, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import type { HistoryController } from "../../../../../src/features/chat/application/threads/history-controller";
|
||||
import { createResumeActions, type ResumeActionsHost } from "../../../../../src/features/chat/application/threads/resume-actions";
|
||||
|
|
@ -75,7 +75,7 @@ describe("ResumeActions", () => {
|
|||
|
||||
expect(resumeThread).toHaveBeenCalledWith("thread");
|
||||
expect(host.syncThreadGoal).toHaveBeenCalledWith("thread");
|
||||
expect(stateStore.getState().activeThread.id).toBe("thread");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("thread");
|
||||
expect(loadLatest).toHaveBeenCalledWith("thread");
|
||||
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
|
||||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
|
|
@ -98,7 +98,7 @@ describe("ResumeActions", () => {
|
|||
await actions.resumeThread("thread");
|
||||
|
||||
expect(resumeThread).toHaveBeenCalledWith("thread");
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBeNull();
|
||||
expect(loadLatest).not.toHaveBeenCalled();
|
||||
expect(host.syncThreadGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -148,11 +148,11 @@ describe("ResumeActions", () => {
|
|||
|
||||
expect(recoverTokenUsageFromRollout).toHaveBeenCalledWith("/tmp/rollout.jsonl");
|
||||
expect(loadLatest).toHaveBeenCalledWith("thread");
|
||||
expect(stateStore.getState().activeThread.tokenUsage).toBeNull();
|
||||
expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull();
|
||||
|
||||
await recovery.resolveAndFlush(tokenUsageFixture(42));
|
||||
|
||||
expect(stateStore.getState().activeThread.tokenUsage).toMatchObject({ last: { inputTokens: 42 } });
|
||||
expect(activeThreadState(stateStore.getState())?.tokenUsage).toMatchObject({ last: { inputTokens: 42 } });
|
||||
});
|
||||
|
||||
it("ignores stale rollout token usage recovery", async () => {
|
||||
|
|
@ -181,8 +181,8 @@ describe("ResumeActions", () => {
|
|||
|
||||
await recovery.resolveAndFlush(tokenUsageFixture(42));
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBe("other");
|
||||
expect(stateStore.getState().activeThread.tokenUsage).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBe("other");
|
||||
expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let late rollout token usage recovery overwrite live token usage", async () => {
|
||||
|
|
@ -196,7 +196,7 @@ describe("ResumeActions", () => {
|
|||
|
||||
await recovery.resolveAndFlush(tokenUsageFixture(42));
|
||||
|
||||
expect(stateStore.getState().activeThread.tokenUsage).toMatchObject({ last: { inputTokens: 99 } });
|
||||
expect(activeThreadState(stateStore.getState())?.tokenUsage).toMatchObject({ last: { inputTokens: 99 } });
|
||||
});
|
||||
|
||||
it("ignores rollout token usage recovery failures", async () => {
|
||||
|
|
@ -207,7 +207,7 @@ describe("ResumeActions", () => {
|
|||
await actions.resumeThread("thread");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(stateStore.getState().activeThread.tokenUsage).toBeNull();
|
||||
expect(activeThreadState(stateStore.getState())?.tokenUsage).toBeNull();
|
||||
expect(host.addSystemMessage).not.toHaveBeenCalledWith("read failed");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Mock } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { activeThreadId } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import {
|
||||
createThreadManagementActions,
|
||||
|
|
@ -443,7 +443,7 @@ describe("thread management actions", () => {
|
|||
rollback.resolve(rollbackSnapshot());
|
||||
await pendingRollback;
|
||||
|
||||
expect(host.stateStore.getState().activeThread.id).toBe("other");
|
||||
expect(activeThreadId(host.stateStore.getState())).toBe("other");
|
||||
expect(host.setComposerText).not.toHaveBeenCalled();
|
||||
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
||||
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
|
||||
|
|
@ -580,7 +580,7 @@ function hostMock({
|
|||
threadTransport: transportOverrides = {},
|
||||
}: {
|
||||
items: ThreadStreamItem[];
|
||||
activeThread?: Partial<ReturnType<typeof chatStateFixture>["activeThread"]>;
|
||||
activeThread?: NonNullable<Parameters<typeof chatStateWith>[1]["activeThread"]>;
|
||||
operations?: Partial<ThreadOperationsMock>;
|
||||
threadTransport?: Partial<ThreadMutationTransportMock>;
|
||||
}): ThreadManagementActionsHostMock {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
|
||||
import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
|
||||
import { resumedThreadAction } from "../../../../../src/features/chat/application/state/actions";
|
||||
import { activeThreadId } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { createThreadStartActions } from "../../../../../src/features/chat/application/threads/thread-start-actions";
|
||||
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
|
||||
|
|
@ -148,7 +148,7 @@ describe("thread start actions", () => {
|
|||
started.resolve(activationFixture(threadFixture("delayed")));
|
||||
|
||||
await expect(starting).resolves.toBeNull();
|
||||
expect(stateStore.getState().activeThread.id).toBe("selected");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("selected");
|
||||
expect(recordStartedThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ describe("thread start actions", () => {
|
|||
});
|
||||
|
||||
await expect(actions.startThread("local preview")).resolves.toBeNull();
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBeNull();
|
||||
expect(stateStore.getState().threadList.listedThreads).toEqual([]);
|
||||
expect(recordStartedThread).not.toHaveBeenCalled();
|
||||
expect(syncThreadGoal).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createChatState } from "../../../../../src/features/chat/application/st
|
|||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { submitComposer } from "../../../../../src/features/chat/application/turns/composer-submit-actions";
|
||||
import { deferred } from "../../../../support/async";
|
||||
import { chatStateWith } from "../../support/state";
|
||||
import { chatStateThreadStreamItems } from "../../support/thread-stream";
|
||||
|
||||
function thread(id: string): Thread {
|
||||
|
|
@ -24,10 +25,8 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) {
|
|||
const initialState = createChatState();
|
||||
const stateStore = createChatStateStore(
|
||||
options.subagent
|
||||
? {
|
||||
...initialState,
|
||||
? chatStateWith(initialState, {
|
||||
activeThread: {
|
||||
...initialState.activeThread,
|
||||
id: "child",
|
||||
provenance: {
|
||||
kind: "subagent",
|
||||
|
|
@ -39,7 +38,7 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) {
|
|||
agentRole: "explorer",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
: initialState,
|
||||
);
|
||||
const interruptTurn = vi.fn().mockResolvedValue({});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { CodexInput } from "../../../../../src/domain/chat/input";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadState, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import {
|
||||
executeSlashCommandWithState,
|
||||
|
|
@ -58,7 +58,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
|
|||
resetReasoningEffortToConfig: vi.fn(),
|
||||
},
|
||||
goals: {
|
||||
activeGoal: vi.fn(() => stateStore.getState().activeThread.goal),
|
||||
activeGoal: vi.fn(() => activeThreadState(stateStore.getState())?.goal ?? null),
|
||||
setObjective: vi.fn().mockResolvedValue(true),
|
||||
setStatus: vi.fn().mockResolvedValue(true),
|
||||
clear: vi.fn().mockResolvedValue(true),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import type { CodexInput } from "../../../../../src/domain/chat/input";
|
||||
import type { Thread } from "../../../../../src/domain/threads/model";
|
||||
import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source";
|
||||
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { activeThreadId, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller";
|
||||
import { optimisticTurnStart } from "../../../../../src/features/chat/application/turns/optimistic-turn-start";
|
||||
|
|
@ -223,7 +223,7 @@ describe("TurnSubmissionActions", () => {
|
|||
});
|
||||
await vi.waitFor(() => expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce());
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBe("thread");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("thread");
|
||||
expect(stateStore.getState().pendingSubmission).toMatchObject({
|
||||
id: pending.id,
|
||||
targetThreadId: "thread",
|
||||
|
|
@ -522,7 +522,7 @@ describe("TurnSubmissionActions", () => {
|
|||
it("applies reserved runtime settings after creating a thread and before starting the turn", async () => {
|
||||
const { host, startTurn, stateStore } = createHost();
|
||||
const applyPendingThreadSettings = vi.fn().mockImplementation(async () => {
|
||||
expect(stateStore.getState().activeThread.id).toBe("thread");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("thread");
|
||||
return true;
|
||||
});
|
||||
host.applyPendingThreadSettings = applyPendingThreadSettings;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { StaleAppServerSharedQueryContextError } from "../../../../src/app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller";
|
||||
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work";
|
||||
|
|
@ -175,7 +176,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
|
||||
await runtime.actions.startNewThread();
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(activeThreadId(stateStore.getState())).toBeNull();
|
||||
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
|
||||
expect(stateStore.getState().connection.statusText).toBe("New chat.");
|
||||
expect(focusComposer).toHaveBeenCalledOnce();
|
||||
|
|
@ -187,6 +188,17 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
it("does not clear the current thread while a turn is busy", async () => {
|
||||
const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined);
|
||||
const { runtime, stateStore } = sessionRuntimeFixture();
|
||||
stateStore.dispatch({
|
||||
type: "turn/optimistic-started",
|
||||
item: {
|
||||
id: "local-user",
|
||||
kind: "dialogue",
|
||||
dialogueKind: "user",
|
||||
role: "user",
|
||||
text: "prompt",
|
||||
},
|
||||
pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] },
|
||||
});
|
||||
stateStore.dispatch({
|
||||
type: "turn/started",
|
||||
threadId: "thread-1",
|
||||
|
|
@ -195,7 +207,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
|
||||
await runtime.actions.startNewThread();
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBe("thread-1");
|
||||
expect(activeThreadId(stateStore.getState())).toBe("thread-1");
|
||||
expect(focusComposer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-r
|
|||
import { deferred } from "../../../support/async";
|
||||
import { installObsidianDomShims } from "../../../support/dom";
|
||||
import { composerReadModelFromChatState, threadStreamReadModelFromChatState } from "../support/shell-read-model";
|
||||
import { chatStateFixture, chatStateWith } from "../support/state";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
|
|
@ -127,7 +128,7 @@ describe("ChatComposerController", () => {
|
|||
});
|
||||
|
||||
it("keeps a pending web submission after a turn that completes during the fetch", () => {
|
||||
const stateStore = createChatStateStore();
|
||||
const stateStore = createChatStateStore(chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } }));
|
||||
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
|
||||
if (!pending) throw new Error("Expected pending web submission");
|
||||
const assistant: ThreadStreamItem = {
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ describe("chat panel surface projections", () => {
|
|||
|
||||
it("projects goal editor and disclosure state before action wiring", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { goal: goalFixture("thread-1") } });
|
||||
state = chatStateWith(state, { activeThread: { id: "thread-1", goal: goalFixture("thread-1") } });
|
||||
state = chatStateWith(state, {
|
||||
ui: { goalEditor: { kind: "editing", threadId: "thread-1", objectiveDraft: "Draft goal", tokenBudgetDraft: 1234 } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { type ChatState, createChatState } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import {
|
||||
activeThreadState,
|
||||
type ChatActiveThreadState,
|
||||
type ChatState,
|
||||
createChatState,
|
||||
} from "../../../../src/features/chat/application/state/root-reducer";
|
||||
|
||||
interface RuntimePatch {
|
||||
active?: Partial<ChatState["runtime"]["active"]>;
|
||||
|
|
@ -8,8 +13,7 @@ interface RuntimePatch {
|
|||
interface ChatStateFixturePatch {
|
||||
connection?: Partial<ChatState["connection"]>;
|
||||
threadList?: Partial<ChatState["threadList"]>;
|
||||
activeThread?: Partial<ChatState["activeThread"]>;
|
||||
restoration?: ChatState["restoration"];
|
||||
activeThread?: Partial<Omit<ChatActiveThreadState, "id">> & { id?: string | null };
|
||||
runtime?: RuntimePatch;
|
||||
turn?: Partial<ChatState["turn"]>;
|
||||
threadStream?: Partial<ChatState["threadStream"]>;
|
||||
|
|
@ -31,8 +35,7 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C
|
|||
...state,
|
||||
...(patch.connection ? { connection: { ...state.connection, ...patch.connection } } : {}),
|
||||
...(patch.threadList ? { threadList: { ...state.threadList, ...patch.threadList } } : {}),
|
||||
...(patch.activeThread ? { activeThread: { ...state.activeThread, ...patch.activeThread } } : {}),
|
||||
...(patch.restoration ? { restoration: patch.restoration } : {}),
|
||||
...(patch.activeThread ? { panelThread: panelThreadWithPatch(state, patch.activeThread) } : {}),
|
||||
...(patch.runtime ? { runtime: runtimeWithPatch(state.runtime, patch.runtime) } : {}),
|
||||
...(patch.turn ? { turn: { ...state.turn, ...patch.turn } } : {}),
|
||||
...(patch.threadStream ? { threadStream: { ...state.threadStream, ...patch.threadStream } } : {}),
|
||||
|
|
@ -50,6 +53,27 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C
|
|||
};
|
||||
}
|
||||
|
||||
function panelThreadWithPatch(state: ChatState, patch: NonNullable<ChatStateFixturePatch["activeThread"]>): ChatState["panelThread"] {
|
||||
if (patch.id === null) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
const current = activeThreadState(state);
|
||||
const id = patch.id ?? current?.id;
|
||||
if (!id) throw new Error("An active thread fixture patch requires a thread id.");
|
||||
return {
|
||||
kind: "active",
|
||||
thread: {
|
||||
id,
|
||||
title: patch.title === undefined ? (current?.title ?? null) : patch.title,
|
||||
cwd: patch.cwd === undefined ? (current?.cwd ?? null) : patch.cwd,
|
||||
goal: patch.goal === undefined ? (current?.goal ?? null) : patch.goal,
|
||||
tokenUsage: patch.tokenUsage === undefined ? (current?.tokenUsage ?? null) : patch.tokenUsage,
|
||||
lifetime: patch.lifetime === undefined ? (current?.lifetime ?? null) : patch.lifetime,
|
||||
provenance: patch.provenance === undefined ? (current?.provenance ?? null) : patch.provenance,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeWithPatch(runtime: ChatState["runtime"], patch: RuntimePatch): ChatState["runtime"] {
|
||||
return {
|
||||
active: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { MarkdownRenderer } from "obsidian";
|
||||
import { act } from "preact/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/turns/plan-implementation";
|
||||
import { implementPlanTarget } from "../../../../../src/features/chat/application/turns/plan-implementation";
|
||||
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
|
||||
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
|
||||
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
||||
|
|
@ -799,7 +799,7 @@ describe("thread stream rendering and action menu", () => {
|
|||
dialogueState: "completed",
|
||||
} as const;
|
||||
const baseState = {
|
||||
activeThread: { id: "thread" },
|
||||
activeThread: { id: "thread", provenance: null },
|
||||
turn: { lifecycle: { kind: "idle" as const } },
|
||||
runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } },
|
||||
threadStream: {
|
||||
|
|
@ -819,11 +819,11 @@ describe("thread stream rendering and action menu", () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(implementPlanTargetFromState(baseState)).toEqual({ itemId: secondPlan.id });
|
||||
expect(implementPlanTarget(baseState)).toEqual({ itemId: secondPlan.id });
|
||||
expect(
|
||||
implementPlanTargetFromState({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }),
|
||||
implementPlanTarget({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }),
|
||||
).toBeNull();
|
||||
expect(implementPlanTargetFromState({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull();
|
||||
expect(implementPlanTarget({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render copy actions for tool items", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue