Model subagent threads as read-only panels

This commit is contained in:
murashit 2026-07-11 23:51:16 +09:00
parent 697b748aaf
commit 160c344008
70 changed files with 650 additions and 62 deletions

View file

@ -52,6 +52,8 @@ Imperative DOM bridges are allowed when an external API, host lifecycle, hit-tes
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
Thread history, archived state, forks, catalog snapshots, and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views of Codex state, not replacements for Codex history.
Routine panel and Threads view refreshes should load only the first recency-ordered thread page. Older active threads are appended through an explicit load-more action; workflows that explicitly need a complete inventory, such as opening the thread picker, may finish pagination on demand.

View file

@ -1,4 +1,4 @@
import type { Thread } from "../../domain/threads/model";
import type { Thread, ThreadProvenance } from "../../domain/threads/model";
export interface ThreadRecord {
id: string;
@ -20,10 +20,53 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt),
provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
};
}
function threadProvenance(thread: ThreadRecord): ThreadProvenance {
const source = recordOrNull(thread["source"]);
const subagent = source?.["subAgent"];
const threadSource = stringOrNull(thread["threadSource"]);
if (subagent === undefined && stringOrNull(thread["parentThreadId"]) === null && !threadSource?.startsWith("subAgent")) {
return { kind: "interactive" };
}
const spawn = recordOrNull(recordOrNull(subagent)?.["thread_spawn"]);
return {
kind: "subagent",
subagentKind: subagentKind(subagent, threadSource),
parentThreadId: stringOrNull(thread["parentThreadId"]) ?? stringOrNull(spawn?.["parent_thread_id"]),
sessionId: stringOrNull(thread["sessionId"]),
depth: finiteNumberOrNull(spawn?.["depth"]),
agentNickname: stringOrNull(thread["agentNickname"]) ?? stringOrNull(spawn?.["agent_nickname"]),
agentRole: stringOrNull(thread["agentRole"]) ?? stringOrNull(spawn?.["agent_role"]),
};
}
function subagentKind(value: unknown, threadSource: string | null): Extract<ThreadProvenance, { kind: "subagent" }>["subagentKind"] {
if (value === "review" || value === "compact" || value === "memory_consolidation") {
return value === "memory_consolidation" ? "memory-consolidation" : value;
}
if (recordOrNull(value)?.["thread_spawn"] || threadSource === "subAgentThreadSpawn") return "thread-spawn";
if (threadSource === "subAgentReview") return "review";
if (threadSource === "subAgentCompact") return "compact";
return "other";
}
function recordOrNull(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function finiteNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function threadsFromThreadRecords(threads: readonly ThreadRecord[], options: { archived?: boolean } = {}): Thread[] {
return threads.map((thread) => threadFromThreadRecord(thread, options));
}

View file

@ -6,6 +6,23 @@ export interface Thread {
readonly createdAt: number;
readonly updatedAt: number;
readonly recencyAt?: number | null;
readonly provenance: ThreadProvenance;
}
export type ThreadProvenance =
| { readonly kind: "interactive" }
| {
readonly kind: "subagent";
readonly subagentKind: "thread-spawn" | "review" | "compact" | "memory-consolidation" | "other";
readonly parentThreadId: string | null;
readonly sessionId: string | null;
readonly depth: number | null;
readonly agentNickname: string | null;
readonly agentRole: string | null;
};
export function isSubagentThread(thread: Thread): boolean {
return thread.provenance.kind === "subagent";
}
export function explicitThreadName(thread: Thread): string | null {

View file

@ -166,14 +166,16 @@ function threadStartedPlan(
state: ChatState,
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
): ChatNotificationPlan {
const effects: ChatNotificationEffect[] = notification.params.thread.ephemeral
? []
: [
{
type: "apply-thread-catalog-event",
event: { type: "thread-started", thread: threadFromAppServerRecord(notification.params.thread) },
},
];
const thread = threadFromAppServerRecord(notification.params.thread);
const effects: ChatNotificationEffect[] =
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
? []
: [
{
type: "apply-thread-catalog-event",
event: { type: "thread-started", thread },
},
];
if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) {
return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects };
}

View file

@ -36,6 +36,7 @@ export interface ComposerSuggestion {
export interface ComposerSuggestionOptions {
activeThreadId?: string | null;
activeThreadEphemeral?: boolean;
activeThreadSubagent?: boolean;
contextReferences?: ComposerContextReferences;
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
@ -114,16 +115,19 @@ export function activeComposerSuggestions(
currentModel: string | null = null,
options: ComposerSuggestionOptions = {},
): ComposerSuggestion[] {
const slashSuggestions = options.activeThreadSubagent
? null
: (activeSlashSubcommandSuggestions(beforeCursor) ??
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false, false));
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
activeTagSuggestions(beforeCursor, options.tagCandidates ?? []) ??
activeSlashSubcommandSuggestions(beforeCursor) ??
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false) ??
slashSuggestions ??
activeSkillSuggestions(beforeCursor, skills) ??
[]
);
@ -395,13 +399,18 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]);
function activeSlashCommandSuggestions(beforeCursor: string, activeThreadEphemeral: boolean): ComposerSuggestion[] | null {
function activeSlashCommandSuggestions(
beforeCursor: string,
activeThreadEphemeral: boolean,
activeThreadSubagent: boolean,
): ComposerSuggestion[] | null {
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
if (match?.index === undefined) return null;
const rawQuery = match[1];
if (rawQuery === undefined) return null;
const query = rawQuery.toLowerCase();
if (activeThreadSubagent) return [];
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
const start = match.index + match[0].lastIndexOf("/");
return SLASH_COMMANDS.filter(

View file

@ -108,11 +108,13 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr
}
async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/model-requested", model });
return applyPendingThreadSettings(host);
}
async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/model-reset-to-config" });
return applyPendingThreadSettings(host);
}
@ -122,11 +124,13 @@ async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: strin
}
async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/reasoning-effort-requested", effort });
return applyPendingThreadSettings(host);
}
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
return applyPendingThreadSettings(host);
}
@ -140,12 +144,14 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
}
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
if (permissionChangesBlocked(host)) return false;
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
return applyPendingThreadSettings(host);
}
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
if (permissionChangesBlocked(host)) return false;
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
return applyPendingThreadSettings(host);
@ -157,12 +163,19 @@ function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
return true;
}
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
if (state(host).activeThread.provenance?.kind !== "subagent") return false;
host.addSystemMessage("Thread settings are unavailable in agent threads.");
return true;
}
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
const { snapshot, config } = runtimeProjection(host);
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
}
async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise<void> {
if (agentThreadSettingsBlocked(host)) return;
const fastMode: RequestedFastMode = mode;
await runRuntimeUiCommand(
host,
@ -181,6 +194,7 @@ async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promis
}
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
const result = await commitPendingThreadSettings(host);
if (result.ok) closeRuntimePanel(host);
@ -191,6 +205,7 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat
}
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
if (agentThreadSettingsBlocked(host)) return;
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
}
@ -201,6 +216,7 @@ async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void>
}
async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise<void> {
if (agentThreadSettingsBlocked(host)) return;
await runRuntimeUiCommand(
host,
async () => {

View file

@ -7,7 +7,7 @@ import {
import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy";
import type { ServerInitialization } from "../../../../domain/server/initialization";
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
import { type Thread, upsertThread } from "../../../../domain/threads/model";
import { isSubagentThread, type Thread, upsertThread } from "../../../../domain/threads/model";
import type { CollaborationModeSelection } from "../../domain/runtime/intent";
import type { ActiveThreadRuntimeState } from "../../domain/runtime/state";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
@ -166,7 +166,9 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
...permissions,
lifetime: { kind: "persistent" },
...(params.items ? { items: params.items } : {}),
...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}),
...(params.listedThreads
? { listedThreads: isSubagentThread(response.thread) ? params.listedThreads : upsertThread(params.listedThreads, response.thread) }
: {}),
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
};
}

View file

@ -106,10 +106,12 @@ interface ChatThreadListState {
export interface ChatActiveThreadState {
readonly id: string | null;
readonly title?: string | null;
readonly cwd: string | null;
readonly goal: ThreadGoal | null;
readonly tokenUsage: ThreadTokenUsage | null;
readonly lifetime: ActiveThreadLifetime | null;
readonly provenance: Thread["provenance"] | null;
}
type ActiveThreadLifetime =
@ -327,10 +329,12 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
},
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,
},
runtime: {
...runtimeBase,
@ -656,6 +660,7 @@ function initialActiveThreadState(): ChatActiveThreadState {
goal: null,
tokenUsage: null,
lifetime: null,
provenance: null,
};
}

View file

@ -112,6 +112,10 @@ async function forkThreadFromTurn(
host.addSystemMessage("Side chats cannot be forked.");
return;
}
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
host.addSystemMessage("Agent threads cannot be forked.");
return;
}
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
@ -177,6 +181,10 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
host.addSystemMessage("Side chats cannot be rolled back.");
return;
}
if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") {
host.addSystemMessage("Agent threads cannot be rolled back.");
return;
}
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Interrupt the current turn before rolling back.");
return;

View file

@ -35,7 +35,8 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
return {
async startNewThread(): Promise<void> {
if (chatTurnBusy(host.stateStore.getState())) return;
const state = host.stateStore.getState();
if (chatTurnBusy(state) && state.activeThread.provenance?.kind !== "subagent") return;
if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return;
host.identity.clearActiveThreadIdentity();

View file

@ -11,6 +11,7 @@ const STATUS_INTERRUPT_REQUESTED = "Interrupt requested.";
export interface ComposerSubmitActionsHost {
stateStore: ChatStateStore;
ensureRestoredThreadLoaded?: () => Promise<boolean>;
composer: {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean; preserveContext?: boolean }): void;
@ -45,16 +46,20 @@ export interface ComposerSubmitActions {
export async function submitComposer(host: ComposerSubmitActionsHost): Promise<void> {
const draft = host.composer.trimmedDraft;
if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return;
const state = submissionStateSnapshot(host.stateStore.getState());
if (state.activeThreadSubagent) {
host.status.addSystemMessage("Messages and slash commands are unavailable in agent threads. Start a new chat to continue.");
return;
}
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host);
return;
}
await sendMessage(host);
await sendMessage(host, draft);
}
async function sendMessage(host: ComposerSubmitActionsHost): Promise<void> {
const text = host.composer.trimmedDraft;
async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promise<void> {
if (!text) return;
const inputSnapshot = host.composer.captureInputSnapshot();

View file

@ -126,6 +126,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
};
const composerSubmitHost: ComposerSubmitActionsHost = {
stateStore,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
composer: {
get trimmedDraft() {
return composer.trimmedDraft();

View file

@ -15,13 +15,14 @@ export interface PlanImplementationHost {
}
export function implementPlanTargetFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
activeThread: Pick<ChatActiveThreadState, "id"> & { provenance?: ChatActiveThreadState["provenance"] };
turn: ChatTurnState;
runtime: { pending: Pick<ChatRuntimeState["pending"], "collaborationMode"> };
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
}): PlanImplementationTarget | null {
if (
!state.activeThread.id ||
state.activeThread.provenance?.kind === "subagent" ||
chatTurnBusy(state) ||
state.runtime.pending.collaborationMode.kind !== "set" ||
state.runtime.pending.collaborationMode.value !== "plan"

View file

@ -31,6 +31,9 @@ export async function executeSlashCommandWithState(
inputSnapshot?: ComposerInputSnapshot,
): Promise<SlashCommandExecutionResult | undefined> {
const state = submissionStateSnapshot(host.stateStore.getState());
if (state.activeThreadSubagent) {
throw new Error("Slash commands are unavailable in agent threads. Start a new chat to continue.");
}
if (!host.connectionAvailable() && command !== "reconnect" && command !== "compact") return;
return runSlashCommand(command, args, {
...host,

View file

@ -7,6 +7,7 @@ import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } f
export interface SubmissionStateSnapshot {
activeThreadId: string | null;
activeThreadEphemeral: boolean;
activeThreadSubagent: boolean;
activeTurnId: string | null;
busy: boolean;
listedThreads: readonly Thread[];
@ -18,6 +19,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
return {
activeThreadId: state.activeThread.id,
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
activeTurnId: activeTurnId(state),
busy: chatTurnBusy(state),
listedThreads: state.threadList.listedThreads,

View file

@ -162,6 +162,9 @@ async function sendTurnText(
}
function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan {
if (state.activeThreadSubagent) {
return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." };
}
if (state.busy) {
return state.activeThreadId && state.activeTurnId
? { kind: "steer", threadId: state.activeThreadId, turnId: state.activeTurnId }

View file

@ -73,6 +73,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId);
void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null);
},
activeThreadChatActionsDisabled: () => stateStore.getState().activeThread.provenance?.kind === "subagent",
});
const toolbarSurface: ChatPanelToolbarSurface = {
connection: {

View file

@ -264,7 +264,7 @@ export class ChatPanelSession implements ChatPanelHandle {
const threadId = this.state.activeThread.id;
if (!threadId) return null;
const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId);
return thread ? threadMeaningfulTitle(thread) : null;
return thread ? threadMeaningfulTitle(thread) : (this.state.activeThread.title ?? null);
}
private restoredThreadTitle(): string | null {

View file

@ -103,6 +103,7 @@ export class ChatComposerController {
draft: model.draft.value,
busy: model.turnBusy.value,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.activeThreadSubagent.value,
normalPlaceholder: projection.placeholder,
suggestions: model.suggestions.value,
selectedSuggestionIndex: model.selectedSuggestionIndex.value,
@ -249,6 +250,7 @@ export class ChatComposerController {
{
activeThreadId: state.activeThread.id,
activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral",
activeThreadSubagent: state.activeThread.provenance?.kind === "subagent",
contextReferences: this.contextReferences(),
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
permissionProfiles: state.connection.availablePermissionProfiles,

View file

@ -80,6 +80,7 @@ interface ChatPanelToolbarDebugState {
export interface ChatPanelToolbarReadModel {
readonly threads: ReadonlySignal<ChatState["threadList"]["listedThreads"]>;
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
readonly toolbarPanel: ReadonlySignal<ChatState["ui"]["toolbarPanel"]>;
@ -138,6 +139,7 @@ export interface ChatPanelComposerReadModel {
readonly suggestions: ReadonlySignal<ChatState["composer"]["suggestions"]>;
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly activeTurnId: ReadonlySignal<string | null>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
@ -179,16 +181,18 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)),
threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)),
threadStreamRollbackCandidate: computed(() =>
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral"
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
? null
: threadStreamRollbackCandidateFromItems(streamItems.value),
),
threadStreamForkCandidates: computed(() =>
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" ? [] : forkCandidatesFromItems(streamItems.value),
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
? []
: forkCandidatesFromItems(streamItems.value),
),
threadStreamImplementPlanTarget: computed(() =>
implementPlanTargetFromState({
activeThread: { id: activeThreadIdSignal.value },
activeThread: { id: activeThreadIdSignal.value, provenance: activeThread.value.provenance },
turn: turn.value,
runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } },
threadStream: threadStream.value,
@ -256,6 +260,7 @@ function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelT
return {
threads: computed(() => signals.threadList.value.listedThreads),
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
turnBusy: signals.turnBusy,
runtimeSnapshot: signals.toolbarRuntimeSnapshot,
toolbarPanel: computed(() => signals.ui.value.toolbarPanel),
@ -334,6 +339,7 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel
suggestions: computed(() => signals.composer.value.suggestions),
selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected),
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
turnBusy: signals.turnBusy,
activeTurnId: signals.activeTurnId,
runtimeSnapshot: signals.composerRuntimeSnapshot,

View file

@ -63,11 +63,9 @@ export function chatPanelComposerProjection(
): ChatPanelComposerProjection {
const snapshot = readModel.runtimeSnapshot.value;
return {
placeholder: composerPlaceholder(
activeComposerThreadName(readModel),
readModel.sideChatActive.value,
readModel.sideChatSourceTitle.value,
),
placeholder: readModel.activeThreadSubagent.value
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(readModel), readModel.sideChatActive.value, readModel.sideChatSourceTitle.value),
meta: {
...composerMetaViewModel(readModel, snapshot),
...runtimeComposerChoices({

View file

@ -39,6 +39,7 @@ interface ToolbarViewModelInput {
interface ToolbarStateProjection {
newChatDisabled: boolean;
activeThreadChatActionsDisabled: boolean;
chatActionsOpen: boolean;
historyOpen: boolean;
statusPanelOpen: boolean;
@ -82,6 +83,7 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
});
return {
newChatDisabled: projection.newChatDisabled,
activeThreadChatActionsDisabled: projection.activeThreadChatActionsDisabled,
chatActionsOpen: projection.chatActionsOpen,
historyOpen: projection.historyOpen,
statusPanelOpen: projection.statusPanelOpen,
@ -140,7 +142,8 @@ function toolbarStateProjection(input: {
const chatActionsOpen = toolbarPanel === "chat-actions";
const statusPanelOpen = toolbarPanel === "status-panel";
return {
newChatDisabled: input.turnBusy,
newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent.value,
activeThreadChatActionsDisabled: input.model.activeThreadSubagent.value,
chatActionsOpen,
historyOpen,
statusPanelOpen,

View file

@ -34,6 +34,7 @@ export interface ToolbarUiActionDependencies {
rename: ThreadRenameEditorActions;
navigation: ThreadNavigationActions;
openSideChat?: () => void;
activeThreadChatActionsDisabled: () => boolean;
}
export interface ToolbarOutsidePointerHit {
@ -135,11 +136,20 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb
startNewThread: () => {
void deps.navigation.startNewThread();
},
...(deps.openSideChat ? { startSideChat: deps.openSideChat } : {}),
...(deps.openSideChat
? {
startSideChat: () => {
if (deps.activeThreadChatActionsDisabled()) return;
deps.openSideChat?.();
},
}
: {}),
compactContext: () => {
if (deps.activeThreadChatActionsDisabled()) return;
void deps.threadActions.compactActiveThread();
},
setGoal: () => {
if (deps.activeThreadChatActionsDisabled()) return;
deps.goals.startEditingCurrent();
},
},

View file

@ -88,6 +88,7 @@ export interface ComposerShellProps {
draft: string;
busy: boolean;
canInterrupt: boolean;
submissionDisabled: boolean;
normalPlaceholder: string;
meta: ComposerMetaViewModel;
suggestions: readonly ComposerSuggestion[];
@ -103,6 +104,7 @@ export function ComposerShell({
draft,
busy,
canInterrupt,
submissionDisabled,
normalPlaceholder,
meta,
suggestions,
@ -147,7 +149,7 @@ export function ComposerShell({
if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor);
onPendingSelectionApplied?.();
}, [draft, pendingSelection, onPendingSelectionApplied]);
const sendMode = composerSendMode(busy, canInterrupt, draft);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled);
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
@ -164,6 +166,7 @@ export function ComposerShell({
aria-controls={composerSuggestionsListId(viewId)}
aria-activedescendant={selectedSuggestionId}
value={draft}
readOnly={submissionDisabled}
onInput={(event) => {
if (syncComposerHeight(event.currentTarget)) callbacks.onHeightChange();
callbacks.onInput(event.currentTarget.value);
@ -184,7 +187,7 @@ export function ComposerShell({
callbacks.onDragOver?.(event);
}}
/>
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} />
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={submissionDisabled} />
</div>
<ComposerSuggestions
containerRef={suggestionsRef}
@ -202,10 +205,12 @@ function ComposerMeta({
meta,
sendMode,
callbacks,
disabled,
}: {
meta: ComposerMetaViewModel;
sendMode: ComposerSendMode;
callbacks: ComposerCallbacks;
disabled: boolean;
}): UiNode {
const metaRef = useRef<HTMLDivElement | null>(null);
const statusRef = useRef<HTMLSpanElement | null>(null);
@ -226,6 +231,7 @@ function ComposerMeta({
});
}, [picker]);
const openPicker = (kind: ComposerMetaPickerKind) => {
if (disabled) return;
const nextPicker = composerMetaPickerState(
kind,
kind === "model" ? modelTriggerRef.current : effortTriggerRef.current,
@ -253,6 +259,7 @@ function ComposerMeta({
<ComposerMetaModeButton
icon="list-todo"
active={meta.planActive}
disabled={disabled}
onMouseDown={() => {
callbacks.onTogglePlan?.();
}}
@ -260,6 +267,7 @@ function ComposerMeta({
<ComposerMetaModeButton
icon="shield"
active={meta.autoReviewActive}
disabled={disabled}
onMouseDown={() => {
callbacks.onToggleAutoReview?.();
}}
@ -267,6 +275,7 @@ function ComposerMeta({
<ComposerMetaModeButton
icon="zap"
active={meta.fastActive}
disabled={disabled}
onMouseDown={() => {
callbacks.onToggleFast?.();
}}
@ -280,6 +289,7 @@ function ComposerMeta({
triggerRef={modelTriggerRef}
kind="model"
value={meta.model}
disabled={disabled}
onMouseDown={() => {
openPicker("model");
}}
@ -292,6 +302,7 @@ function ComposerMeta({
triggerRef={effortTriggerRef}
kind="effort"
value={meta.effort}
disabled={disabled}
onMouseDown={() => {
openPicker("effort");
}}
@ -337,7 +348,17 @@ function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["con
);
}
function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; active: boolean; onMouseDown: () => void }): UiNode {
function ComposerMetaModeButton({
icon,
active,
disabled,
onMouseDown,
}: {
icon: string;
active: boolean;
disabled: boolean;
onMouseDown: () => void;
}): UiNode {
const iconRef = useRef<HTMLSpanElement | null>(null);
useLayoutEffect(() => {
const element = iconRef.current;
@ -348,12 +369,17 @@ function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; a
<span
ref={iconRef}
aria-hidden="true"
className={["codex-panel__composer-meta-trigger", "codex-panel__composer-meta-icon", active ? "is-active" : ""]
className={[
"codex-panel__composer-meta-trigger",
"codex-panel__composer-meta-icon",
active ? "is-active" : "",
disabled ? "is-disabled" : "",
]
.filter(Boolean)
.join(" ")}
onMouseDown={(event) => {
event.preventDefault();
onMouseDown();
if (!disabled) onMouseDown();
}}
/>
);
@ -363,21 +389,30 @@ function ComposerMetaPickerButton({
triggerRef,
kind,
value,
disabled,
onMouseDown,
}: {
triggerRef: Ref<HTMLSpanElement>;
kind: ComposerMetaPickerKind;
value: string;
disabled: boolean;
onMouseDown: () => void;
}): UiNode {
return (
// biome-ignore lint/a11y: Composer meta triggers are visual pointer shortcuts; screen readers get the status summary and full runtime controls remain available through the toolbar and slash commands.
<span
ref={triggerRef}
className={`codex-panel__composer-meta-trigger codex-panel__composer-meta-value codex-panel__composer-meta-${kind}`}
className={[
"codex-panel__composer-meta-trigger",
"codex-panel__composer-meta-value",
`codex-panel__composer-meta-${kind}`,
disabled ? "is-disabled" : "",
]
.filter(Boolean)
.join(" ")}
onMouseDown={(event) => {
event.preventDefault();
onMouseDown();
if (!disabled) onMouseDown();
}}
>
{value}
@ -436,7 +471,7 @@ interface ComposerSendMode {
canInterrupt: boolean;
}
function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string): ComposerSendMode {
function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string, submissionDisabled: boolean): ComposerSendMode {
const hasDraft = Boolean(draft.trim());
const canSteer = canInterrupt && hasDraft;
const interruptMode = canInterrupt && !hasDraft;
@ -444,7 +479,7 @@ function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string):
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
disabled: busy && !canInterrupt,
disabled: submissionDisabled || (busy && !canInterrupt),
canInterrupt,
};
}

View file

@ -34,6 +34,7 @@ interface ToolbarStatusSection {
export interface ToolbarViewModel {
newChatDisabled: boolean;
activeThreadChatActionsDisabled: boolean;
chatActionsOpen: boolean;
historyOpen: boolean;
statusPanelOpen: boolean;
@ -175,10 +176,20 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions
actions.startSideChat?.();
}}
className="codex-panel__chat-actions-panel-item"
disabled={model.threads.every((thread) => !thread.selected)}
disabled={model.activeThreadChatActionsDisabled || model.threads.every((thread) => !thread.selected)}
/>
<ToolbarPanelItem
label="Compact context"
onClick={actions.compactContext}
className="codex-panel__chat-actions-panel-item"
disabled={model.activeThreadChatActionsDisabled}
/>
<ToolbarPanelItem
label="Set goal..."
onClick={actions.setGoal}
className="codex-panel__chat-actions-panel-item"
disabled={model.activeThreadChatActionsDisabled}
/>
<ToolbarPanelItem label="Compact context" onClick={actions.compactContext} className="codex-panel__chat-actions-panel-item" />
<ToolbarPanelItem label="Set goal..." onClick={actions.setGoal} className="codex-panel__chat-actions-panel-item" />
</div>
);
}

View file

@ -599,6 +599,7 @@ function thread(id: string, archived = false) {
archived,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" as const },
};
}

View file

@ -174,6 +174,7 @@ function thread(id: string): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -1,9 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
import { forkEphemeralThread, listThreads, startEphemeralThread, startThread } from "../../src/app-server/services/threads";
import {
forkEphemeralThread,
listThreads,
startEphemeralThread,
startThread,
threadFromAppServerRecord,
} from "../../src/app-server/services/threads";
describe("app-server thread response adapters", () => {
it("preserves spawned subagent provenance in the domain thread", () => {
const thread = threadFromAppServerRecord({
id: "child",
preview: "Inspect",
name: null,
createdAt: 1,
updatedAt: 2,
sessionId: "session",
parentThreadId: "parent",
source: {
subAgent: {
thread_spawn: {
parent_thread_id: "parent",
depth: 2,
agent_path: null,
agent_nickname: "Scout",
agent_role: "explorer",
},
},
},
agentNickname: "Scout",
agentRole: "explorer",
});
expect(thread.provenance).toEqual({
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 2,
agentNickname: "Scout",
agentRole: "explorer",
});
});
it("forks read-only ephemeral side-chat threads", async () => {
const client = {
request: vi.fn().mockResolvedValue({
@ -90,7 +131,15 @@ describe("app-server thread response adapters", () => {
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault", { archived: true })).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: true, createdAt: 10, updatedAt: 20 },
{
id: "thread-1",
preview: "Preview",
name: null,
archived: true,
createdAt: 10,
updatedAt: 20,
provenance: { kind: "interactive" },
},
]);
expect(clientListThreads).toHaveBeenCalledWith("thread/list", {
cwd: "/vault",
@ -109,7 +158,16 @@ describe("app-server thread response adapters", () => {
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: 30 },
{
id: "thread-1",
preview: "Preview",
name: null,
archived: false,
createdAt: 10,
updatedAt: 20,
recencyAt: 30,
provenance: { kind: "interactive" },
},
]);
});
@ -121,7 +179,16 @@ describe("app-server thread response adapters", () => {
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: null },
{
id: "thread-1",
preview: "Preview",
name: null,
archived: false,
createdAt: 10,
updatedAt: 20,
recencyAt: null,
provenance: { kind: "interactive" },
},
]);
});
@ -141,8 +208,24 @@ describe("app-server thread response adapters", () => {
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "First", name: null, archived: false, createdAt: 10, updatedAt: 20 },
{ id: "thread-2", preview: "Second", name: null, archived: false, createdAt: 30, updatedAt: 40 },
{
id: "thread-1",
preview: "First",
name: null,
archived: false,
createdAt: 10,
updatedAt: 20,
provenance: { kind: "interactive" },
},
{
id: "thread-2",
preview: "Second",
name: null,
archived: false,
createdAt: 30,
updatedAt: 40,
provenance: { kind: "interactive" },
},
]);
expect(clientListThreads).toHaveBeenNthCalledWith(1, "thread/list", {
cwd: "/vault",

View file

@ -227,6 +227,7 @@ function thread(
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
transcriptEntries: [],
...overrides,
};

View file

@ -11,6 +11,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: "参照元",
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -71,5 +71,6 @@ function thread(options: Partial<Thread> & { id: string }): Thread {
...(options.recencyAt === undefined ? {} : { recencyAt: options.recencyAt }),
name: options.name ?? null,
archived: false,
provenance: options.provenance ?? { kind: "interactive" },
};
}

View file

@ -107,6 +107,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -1433,6 +1433,37 @@ describe("ChatInboundHandler", () => {
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
});
it("keeps subagent thread-started notifications out of the shared catalog", () => {
const applyThreadCatalogEvent = vi.fn();
const handler = handlerForState(chatStateFixture(), { applyThreadCatalogEvent });
const child = appServerThread("child", "/workspace/active");
handler.handleNotification({
method: "thread/started",
params: {
thread: {
...child,
parentThreadId: "parent",
source: {
subAgent: {
thread_spawn: {
parent_thread_id: "parent",
depth: 1,
agent_path: null,
agent_nickname: "Scout",
agent_role: "explorer",
},
},
},
agentNickname: "Scout",
agentRole: "explorer",
},
},
} satisfies Extract<ServerNotification, { method: "thread/started" }>);
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
});
it("replaces optimistic user echoes when completed turns are reconciled", () => {
let state = activeRunningState();
state = withChatStateThreadStreamItems(state, [
@ -2219,6 +2250,7 @@ function panelThread(id: string): PanelThread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -144,7 +144,15 @@ describe("turn item conversion preserves app-server semantics", () => {
it("hides persisted /refer context in displayed user messages", () => {
const { prompt: text } = referencedThreadPromptBundle(
{ id: "thread-reference", name: "参照元", preview: "", archived: false, createdAt: 1, updatedAt: 1 } satisfies Thread,
{
id: "thread-reference",
name: "参照元",
preview: "",
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
} satisfies Thread,
[
{ userText: "元の依頼", assistantText: "元の回答" },
{ userText: "次の依頼", assistantText: "次の回答" },

View file

@ -446,7 +446,15 @@ describe("chat app-server transports", () => {
});
const result = await resolver.referThread(
{ id: "019abcde-0000-7000-8000-000000000001", preview: "", name: "Other", createdAt: 1, updatedAt: 1, archived: false },
{
id: "019abcde-0000-7000-8000-000000000001",
preview: "",
name: "Other",
createdAt: 1,
updatedAt: 1,
archived: false,
provenance: { kind: "interactive" },
},
"summarize",
inputSnapshot,
);

View file

@ -38,6 +38,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}
@ -291,6 +292,14 @@ describe("composer suggestions", () => {
expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).toContain("/goal");
});
it("omits slash suggestions from subagent threads", () => {
const options = { activeThreadSubagent: true };
expect(activeComposerSuggestions("/", notes, [], [], [], null, options)).toEqual([]);
expect(activeComposerSuggestions("/permissions ", notes, [], [], [], null, options)).toEqual([]);
expect(activeComposerSuggestions("/resume ", notes, [], [], [], null, options)).toEqual([]);
expect(activeComposerSuggestions("/model ", notes, [], [], [], null, options)).toEqual([]);
});
it("bounds selection preview text before rendering it in the suggestion list", () => {
const suggestion = activeComposerSuggestions("@sel", notes, [], [], [], null, {
contextReferences: {

View file

@ -114,6 +114,39 @@ describe("createChatRuntimeSettingsActions", () => {
expect(messages).toEqual(["Permission changes are unavailable in side chats.", "Permission changes are unavailable in side chats."]);
});
it("blocks all thread setting changes for subagent threads", async () => {
let state = chatStateFixture();
state = chatStateWith(state, {
activeThread: {
id: "child",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: null,
agentRole: null,
},
},
});
const store = createChatStateStore(state);
const transport = settingsTransportFixture();
const messages: string[] = [];
const actions = runtimeActionsFixture(store, transport, messages);
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false);
await expect(actions.requestReasoningEffort("high")).resolves.toBe(false);
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(false);
await expect(actions.setCollaborationMode("plan")).resolves.toBe(false);
await actions.enableFastMode();
await actions.enableAutoReview();
expect(transport.updateThreadSettings).not.toHaveBeenCalled();
expect(messages).toHaveLength(6);
expect(new Set(messages)).toEqual(new Set(["Thread settings are unavailable in agent threads."]));
});
it("reserves thread runtime settings when no thread is active", async () => {
const store = createChatStateStore(chatStateFixture());
const transport = settingsTransportFixture();

View file

@ -88,6 +88,27 @@ describe("chat thread resume helpers", () => {
});
expect(action.listedThreads).toBeUndefined();
});
it("keeps resumed subagent threads out of the ordinary thread list", () => {
const existing = threadFixture("existing", "Existing");
const subagent = {
...threadFixture("child", "Child"),
provenance: {
kind: "subagent" as const,
subagentKind: "thread-spawn" as const,
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
};
const action = resumedThreadAction({ response: responseFixture(subagent), listedThreads: [existing] });
expect(action.thread.provenance).toEqual(subagent.provenance);
expect(action.listedThreads).toEqual([existing]);
});
});
function responseFixture(thread: Thread): ThreadActivationSnapshot {
@ -119,6 +140,7 @@ function threadFixture(id: string, name: string): Thread {
preview: "",
name,
archived: false,
provenance: { kind: "interactive" },
createdAt: 1,
updatedAt: 1,
};

View file

@ -884,5 +884,6 @@ function thread(id: string): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -14,6 +14,7 @@ function thread(id: string, name: string | null = null): Thread {
updatedAt: 0,
name,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -200,6 +200,7 @@ function threadFixture(id: string): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -10,7 +10,17 @@ describe("ephemeral thread lifecycle", () => {
const store = createChatStateStore();
store.dispatch({
type: "thread-list/applied",
threads: [{ id: "source", preview: "Source", name: null, archived: false, createdAt: 1, updatedAt: 1 }],
threads: [
{
id: "source",
preview: "Source",
name: null,
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
},
],
});
const transport = transportMock();
const lifecycle = createEphemeralThreadLifecycle({
@ -151,7 +161,15 @@ function transportMock(): EphemeralThreadTransport {
function activationFixture(): ThreadActivationSnapshot {
return {
thread: { id: "side", preview: "", name: null, archived: false, createdAt: 1, updatedAt: 1 },
thread: {
id: "side",
preview: "",
name: null,
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
},
cwd: "/vault",
model: "gpt-5.5",
serviceTier: null,

View file

@ -193,7 +193,15 @@ describe("createGoalActions", () => {
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 },
thread: {
id: "thread-new",
name: null,
preview: "Plan release",
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
},
cwd: "/vault",
model: null,
reasoningEffort: null,

View file

@ -214,6 +214,7 @@ function threadFixture(id: string): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -226,6 +226,7 @@ function panelThread(id: string): PanelThread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -599,6 +599,7 @@ function panelThread(id: string, overrides: Partial<Thread> = {}): Thread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -8,7 +8,7 @@ import {
type ThreadNavigationActionsHost,
} from "../../../../../src/features/chat/application/threads/thread-navigation-actions";
function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
function resumeThreadState(stateStore: ChatStateStore, threadId: string, subagent = false): void {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -17,7 +17,21 @@ function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: threadId, cliVersion: "test" } as never,
thread: {
id: threadId,
cliVersion: "test",
provenance: subagent
? {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
}
: { kind: "interactive" },
} as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
@ -69,6 +83,17 @@ describe("ThreadNavigationActions", () => {
expect(host.focusComposer).not.toHaveBeenCalled();
});
it("starts a blank chat while a subagent turn continues", async () => {
const { actions, host, stateStore } = createActionsHarness();
resumeThreadState(stateStore, "child", true);
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
await actions.startNewThread();
expect(host.identity.clearActiveThreadIdentity).toHaveBeenCalledOnce();
expect(host.focusComposer).toHaveBeenCalledOnce();
});
it("focuses an already open thread without resuming it", async () => {
const { actions, host } = createActionsHarness({
focusThreadInOpenView: vi.fn().mockResolvedValue(true),

View file

@ -178,6 +178,7 @@ function threadFixture(id: string, overrides: Partial<Thread> = {}): Thread {
preview: "",
name: null,
archived: false,
provenance: { kind: "interactive" },
createdAt: 0,
updatedAt: 0,
recencyAt: null,

View file

@ -13,11 +13,32 @@ function thread(id: string): Thread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}
function createHost(draft: string) {
const stateStore = createChatStateStore(createChatState());
function createHost(draft: string, options: { subagent?: boolean } = {}) {
const initialState = createChatState();
const stateStore = createChatStateStore(
options.subagent
? {
...initialState,
activeThread: {
...initialState.activeThread,
id: "child",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
},
}
: initialState,
);
const interruptTurn = vi.fn().mockResolvedValue({});
const setDraft = vi.fn();
const sendTurnText = vi.fn().mockResolvedValue(true);
@ -62,6 +83,18 @@ function createHost(draft: string) {
}
describe("submitComposer", () => {
it.each(["hello", "/status"])("blocks composer submission from subagent threads for %s", async (draft) => {
const { host, execute, sendTurnText } = createHost(draft, { subagent: true });
await submitComposer(host);
expect(execute).not.toHaveBeenCalled();
expect(sendTurnText).not.toHaveBeenCalled();
expect(host.status.addSystemMessage).toHaveBeenCalledWith(
"Messages and slash commands are unavailable in agent threads. Start a new chat to continue.",
);
});
it("sends plain drafts as turn text", async () => {
const { host, ensureConnected, inputSnapshot, sendTurnText, showLatest } = createHost("hello");

View file

@ -28,6 +28,7 @@ function thread(id: string): Thread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -75,6 +75,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -19,6 +19,7 @@ function thread(id: string, name: string | null = null): Thread {
updatedAt: 0,
name,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -22,6 +22,7 @@ function thread(id: string): Thread {
updatedAt: 0,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}
@ -75,7 +76,52 @@ function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
});
}
function resumeSubagentThread(stateStore: ReturnType<typeof createChatStateStore>) {
const child: Thread = {
...thread("child"),
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: null,
agentRole: null,
},
};
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: child,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
}
describe("TurnSubmissionActions", () => {
it("blocks direct turn submission after a restored thread resolves to a subagent", async () => {
const { host, startTurn, stateStore } = createHost({
ensureRestoredThreadLoaded: vi.fn().mockImplementation(async () => {
resumeSubagentThread(stateStore);
return true;
}),
});
const actions = createTurnSubmissionActions(host);
await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(false);
expect(startTurn).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue.");
});
it("starts a thread when needed and acknowledges the optimistic turn", async () => {
const { host, startTurn, stateStore } = createHost();
const actions = createTurnSubmissionActions(host);

View file

@ -380,6 +380,7 @@ describe("createChatPanelSessionGraph actions", () => {
preview: "",
name: null,
archived: false,
provenance: { kind: "interactive" },
createdAt: 1,
updatedAt: 1,
...overrides,

View file

@ -884,6 +884,7 @@ function threadFromRecord(record: ThreadRecord): Thread {
preview: record.preview,
name: record.name,
archived: false,
provenance: { kind: "interactive" },
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
@ -922,6 +923,7 @@ function panelThread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -443,6 +443,7 @@ function shellParts(
draft: model.draft.value,
busy: false,
canInterrupt: false,
submissionDisabled: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,

View file

@ -25,6 +25,39 @@ import { withChatStateThreadStreamItems } from "../../support/thread-stream";
installObsidianDomShims();
describe("chat panel surface projections", () => {
it("disables subagent chat actions except starting a new chat", () => {
let state = chatStateFixture();
state = chatStateWith(state, {
activeThread: {
id: "child",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
},
turn: { lifecycle: { kind: "running", turnId: "child-turn" } },
ui: { toolbarPanel: "chat-actions" },
});
const actions = toolbarActionsFixture();
const parent = renderWithShellReadModel(state, (readModel) =>
h(ChatPanelToolbar, { model: readModel.toolbar, surface: toolbarSurfaceFixture(), actions }),
);
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
expect(items.map((item) => [item.textContent, item.classList.contains("is-disabled")])).toEqual([
["Start new chat", false],
["Start side chat", true],
["Compact context", true],
["Set goal...", true],
]);
unmountUiRoot(parent);
});
it("does not project rollback actions for side chats", () => {
let state = chatStateFixture();
state = chatStateWith(state, {
@ -571,6 +604,7 @@ function threadFixture(id: string, name: string | null): Thread {
updatedAt: 1,
name,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -48,7 +48,15 @@ describe("ThreadStreamPresenter scroll pinning", () => {
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-1", preview: "", archived: false, createdAt: 1, updatedAt: 1, name: "Thread" },
thread: {
id: "thread-1",
preview: "",
archived: false,
createdAt: 1,
updatedAt: 1,
name: "Thread",
provenance: { kind: "interactive" },
},
cwd: "/repo",
model: null,
reasoningEffort: null,

View file

@ -123,6 +123,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
draft: "",
busy: false,
canInterrupt: false,
submissionDisabled: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,
@ -215,6 +216,7 @@ function threadFixture(id: string, name: string): Thread {
name,
preview: "",
archived: false,
provenance: { kind: "interactive" },
createdAt: 1,
updatedAt: 1,
};

View file

@ -31,6 +31,7 @@ function mountComposerShell(
draft,
busy,
canInterrupt,
submissionDisabled: false,
normalPlaceholder,
suggestions,
selectedSuggestionIndex,
@ -413,6 +414,7 @@ describe("ComposerShell decisions", () => {
draft: "line one\nline two",
busy: false,
canInterrupt: false,
submissionDisabled: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,
@ -450,6 +452,7 @@ describe("ComposerShell decisions", () => {
draft: "",
busy: false,
canInterrupt: false,
submissionDisabled: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,

View file

@ -405,6 +405,7 @@ describe("Toolbar decisions", () => {
function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewModel {
return {
newChatDisabled: false,
activeThreadChatActionsDisabled: false,
chatActionsOpen: false,
historyOpen: false,
statusPanelOpen: false,

View file

@ -152,5 +152,6 @@ function thread(options: Partial<Thread> & { id: string }): Thread {
...(options.recencyAt === undefined ? {} : { recencyAt: options.recencyAt }),
name: options.name ?? null,
archived: false,
provenance: options.provenance ?? { kind: "interactive" },
};
}

View file

@ -31,6 +31,7 @@ function threadFixture(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -58,6 +58,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
preview: "",
name: null,
archived: false,
provenance: { kind: "interactive" },
createdAt: 1,
updatedAt: 1,
...overrides,

View file

@ -665,6 +665,7 @@ function threadFromRecord(record: Record<string, unknown>): Thread {
preview: typeof record["preview"] === "string" ? record["preview"] : "",
name: typeof record["name"] === "string" ? record["name"] : null,
archived: false,
provenance: { kind: "interactive" },
createdAt: Number(record["createdAt"] ?? 0),
updatedAt: Number(record["updatedAt"] ?? 0),
};

View file

@ -587,6 +587,7 @@ function thread(id: string, archived = false, overrides: Partial<Thread> = {}):
updatedAt: 1,
name: null,
archived,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -40,6 +40,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
...overrides,
};
}

View file

@ -72,6 +72,7 @@ function thread(overrides: Partial<ArchiveThreadInput> = {}): ArchiveThreadInput
updatedAt: 1,
name: "Thread",
archived: false,
provenance: { kind: "interactive" },
transcriptEntries: [transcriptEntry("user", "Hello", 1)],
...overrides,
};

View file

@ -893,6 +893,7 @@ function thread(id: string): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}

View file

@ -898,6 +898,7 @@ function panelThread(overrides: Partial<Thread> = {}): Thread {
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}