refactor(chat): model panel thread phase explicitly

This commit is contained in:
murashit 2026-07-16 18:50:26 +09:00
parent a099e1fffc
commit d202849313
46 changed files with 413 additions and 267 deletions

View file

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

View file

@ -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: [] };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
}),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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