feat(chat): honor app-server direct input capability

This commit is contained in:
murashit 2026-07-22 09:15:45 +09:00
parent 73be2beea7
commit 01a20dcf70
30 changed files with 237 additions and 22 deletions

View file

@ -72,7 +72,7 @@ Foreground reveal and focus are the narrow workspace-wide exception: the latest
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
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.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.

View file

@ -21,6 +21,7 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt),
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
};

View file

@ -6,6 +6,11 @@ export interface Thread {
readonly createdAt: number;
readonly updatedAt: number;
readonly recencyAt?: number | null;
/**
* Whether the loaded app-server thread accepts direct turn input.
* `null` means the capability is unavailable, so panel mode policy decides.
*/
readonly canAcceptDirectInput?: boolean | null;
readonly provenance: ThreadProvenance;
}

View file

@ -30,6 +30,7 @@ type ActivePanelThreadFacts =
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null;
};
@ -49,6 +50,13 @@ function activePanelOperationDecisionForFacts(
}
if (facts.phase === "empty") return ALLOWED;
if (operation === "submit") {
if (facts.canAcceptDirectInput !== null) return facts.canAcceptDirectInput ? ALLOWED : directInputBlocked();
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
return ALLOWED;
}
// Keep the stricter interpretation if a malformed or future state contains
// both mode facts. This makes mode restrictions monotonically safe.
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
@ -68,10 +76,15 @@ function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
return {
phase: "active",
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
provenance: panelThread.thread.provenance?.kind ?? null,
};
}
function directInputBlocked(): ActivePanelOperationDecision {
return { kind: "blocked", message: "This thread cannot accept messages." };
}
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
switch (operation) {
case "submit":

View file

@ -123,6 +123,7 @@ export interface ChatActiveThreadState {
readonly goal: ThreadGoal | null;
readonly tokenUsage: ThreadTokenUsage | null;
readonly lifetime: ActiveThreadLifetime | null;
readonly canAcceptDirectInput: boolean | null;
readonly provenance: Thread["provenance"] | null;
}
@ -463,6 +464,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
goal: null,
tokenUsage: null,
lifetime: action.lifetime ?? { kind: "persistent" },
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
provenance: action.thread.provenance,
},
},
@ -900,6 +902,7 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
goal: null,
tokenUsage: null,
lifetime: null,
canAcceptDirectInput: null,
provenance: null,
};
}

View file

@ -77,12 +77,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
const state = submissionStateSnapshot(chatState);
if (host.stateStore.getState().pendingSubmission) return;
const operationDecision = activePanelOperationDecision(chatState, "submit");
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
if (state.busy && state.activeThreadId && state.activeTurnId && (draft.length === 0 || operationDecision.kind === "blocked")) {
await interruptTurn(host, panelTarget);
return;
}
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host, panelTarget);
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
return;
}
await sendMessage(host, draft, originalDraft, panelTarget);

View file

@ -111,7 +111,8 @@ export class ChatComposerController {
draft: model.draft,
busy: model.turnBusy,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
submissionDisabled: model.webSubmissionPending,
directInputDisabled: model.submissionBlockedByPanelPolicy,
runtimeControlsDisabled: model.runtimeSettingsDisabled,
sendDisabled: model.attachmentSavePending,
webSubmissionCancellable: model.webSubmissionCancellable,

View file

@ -70,6 +70,7 @@ export interface ChatPanelComposerModel {
readonly activeThreadId: string | null;
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
readonly activeThreadSubagent: boolean;
readonly canAcceptDirectInput: ChatActiveThreadState["canAcceptDirectInput"];
readonly submissionBlockedByPanelPolicy: boolean;
readonly runtimeSettingsDisabled: boolean;
readonly webSubmissionPending: boolean;
@ -155,6 +156,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
activeThreadId,
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
canAcceptDirectInput: activeThread?.canAcceptDirectInput ?? null,
submissionBlockedByPanelPolicy: activePanelOperationDecision(state, "submit").kind === "blocked",
runtimeSettingsDisabled: activePanelOperationDecision(state, "thread-settings").kind !== "allowed",
webSubmissionPending: state.pendingSubmission !== null,

View file

@ -71,9 +71,12 @@ export function chatPanelComposerProjection(
availableModels: model.availableModels,
});
return {
placeholder: model.activeThreadSubagent
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
placeholder:
model.canAcceptDirectInput === false
? "This thread cannot accept messages."
: model.activeThreadSubagent && model.canAcceptDirectInput === null
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
meta: {
...composerMetaViewModel(model, snapshot),
...runtimeComposerChoices({

View file

@ -88,6 +88,7 @@ export interface ComposerShellProps {
busy: boolean;
canInterrupt: boolean;
submissionDisabled: boolean;
directInputDisabled: boolean;
runtimeControlsDisabled: boolean;
sendDisabled: boolean;
webSubmissionCancellable: boolean;
@ -107,6 +108,7 @@ export function ComposerShell({
busy,
canInterrupt,
submissionDisabled,
directInputDisabled,
runtimeControlsDisabled,
sendDisabled,
webSubmissionCancellable,
@ -154,8 +156,16 @@ export function ComposerShell({
if (pendingSelection.value === draft) restoreComposerSelection(composerRef.current, pendingSelection);
onPendingSelectionApplied?.();
}, [draft, pendingSelection, onPendingSelectionApplied]);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, sendDisabled, webSubmissionCancellable);
const composerLocked = submissionDisabled;
const sendMode = composerSendMode(
busy,
canInterrupt,
draft,
submissionDisabled,
directInputDisabled,
sendDisabled,
webSubmissionCancellable,
);
const composerLocked = submissionDisabled || directInputDisabled;
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
@ -165,7 +175,7 @@ export function ComposerShell({
<textarea
ref={composerRef}
className="codex-panel-ui__text-input codex-panel__composer-input"
placeholder={sendMode.canInterrupt ? "Steer the current turn..." : normalPlaceholder}
placeholder={sendMode.canInterrupt && !composerLocked ? "Steer the current turn..." : normalPlaceholder}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestions.length > 0 ? "true" : "false"}
@ -482,6 +492,7 @@ function composerSendMode(
canInterrupt: boolean,
draft: string,
submissionDisabled: boolean,
directInputDisabled: boolean,
sendDisabled: boolean,
webSubmissionCancellable: boolean,
): ComposerSendMode {
@ -495,13 +506,13 @@ function composerSendMode(
};
}
const hasDraft = Boolean(draft.trim());
const canSteer = canInterrupt && hasDraft;
const interruptMode = canInterrupt && !hasDraft;
const interruptMode = canInterrupt && (!hasDraft || directInputDisabled);
const canSteer = canInterrupt && hasDraft && !directInputDisabled;
return {
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
disabled: submissionDisabled || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
disabled: submissionDisabled || (directInputDisabled && !interruptMode) || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
canInterrupt,
};
}

View file

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

View file

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

View file

@ -46,6 +46,19 @@ describe("app-server thread response adapters", () => {
});
});
it("preserves direct-input capability from app-server threads", () => {
const thread = threadFromAppServerRecord({
id: "thread",
preview: "",
name: null,
createdAt: 1,
updatedAt: 2,
canAcceptDirectInput: false,
});
expect(thread.canAcceptDirectInput).toBe(false);
});
it("forks read-only ephemeral side-chat threads behind a model-visible boundary", async () => {
const client = {
request: vi.fn((method: string) => {
@ -215,6 +228,7 @@ describe("app-server thread response adapters", () => {
archived: true,
createdAt: 10,
updatedAt: 20,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
]);
@ -242,6 +256,7 @@ describe("app-server thread response adapters", () => {
createdAt: 10,
updatedAt: 20,
recencyAt: 30,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
]);
@ -263,6 +278,7 @@ describe("app-server thread response adapters", () => {
createdAt: 10,
updatedAt: 20,
recencyAt: null,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
]);
@ -291,6 +307,7 @@ describe("app-server thread response adapters", () => {
archived: false,
createdAt: 10,
updatedAt: 20,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
{
@ -300,6 +317,7 @@ describe("app-server thread response adapters", () => {
archived: false,
createdAt: 30,
updatedAt: 40,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ describe("thread reference resolver", () => {
createdAt: 1,
updatedAt: 1,
archived: false,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
},
"summarize",

View file

@ -8,6 +8,7 @@ type ActivePanelThreadFacts =
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null;
};
@ -26,22 +27,43 @@ const operations: readonly ActivePanelOperation[] = [
describe("activePanelOperationDecisionForFacts", () => {
it("allows every mode-derived operation in an interactive persistent panel", () => {
expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "interactive" }, allAllowed());
expectDecisionKinds({ phase: "active", lifetime: "persistent", canAcceptDirectInput: null, provenance: "interactive" }, allAllowed());
});
it("keeps side-chat submission and compaction available while blocking persistent-thread mutations", () => {
expectDecisionKinds(
{ phase: "active", lifetime: "ephemeral", provenance: "interactive" },
{ phase: "active", lifetime: "ephemeral", canAcceptDirectInput: null, provenance: "interactive" },
decisions({ submit: "allowed", compact: "allowed" }),
);
});
it("keeps subagent panels read-only", () => {
expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "subagent" }, decisions({ "goal-read": "allowed" }));
it("keeps subagent panels read-only when the server capability is unavailable", () => {
expectDecisionKinds(
{ phase: "active", lifetime: "persistent", canAcceptDirectInput: null, provenance: "subagent" },
decisions({ "goal-read": "allowed" }),
);
});
it("uses the server capability for subagent submission while preserving other subagent restrictions", () => {
expectDecisionKinds(
{ phase: "active", lifetime: "persistent", canAcceptDirectInput: true, provenance: "subagent" },
decisions({ submit: "allowed", "goal-read": "allowed" }),
);
expectDecisionKinds(
{ phase: "active", lifetime: "persistent", canAcceptDirectInput: false, provenance: "subagent" },
decisions({ "goal-read": "allowed" }),
);
});
it("keeps the stricter subagent restrictions when mode facts conflict", () => {
expectDecisionKinds({ phase: "active", lifetime: "ephemeral", provenance: "subagent" }, decisions({}));
expectDecisionKinds({ phase: "active", lifetime: "ephemeral", canAcceptDirectInput: null, provenance: "subagent" }, decisions({}));
});
it("blocks only submission when the loaded thread rejects direct input", () => {
expectDecisionKinds(
{ phase: "active", lifetime: "persistent", canAcceptDirectInput: false, provenance: "interactive" },
allAllowedExcept("submit"),
);
});
it("requires restoration before a persistent goal mutation", () => {
@ -73,6 +95,10 @@ function decisions(allowed: Partial<Record<ActivePanelOperation, "allowed">>): R
>;
}
function allAllowedExcept(blocked: ActivePanelOperation): Record<ActivePanelOperation, "allowed" | "blocked"> {
return decisions(Object.fromEntries(operations.filter((operation) => operation !== blocked).map((operation) => [operation, "allowed"])));
}
function expectDecisionKinds(facts: ActivePanelThreadFacts, expected: Record<ActivePanelOperation, "allowed" | "blocked">): void {
for (const operation of operations) {
expect(activePanelOperationDecision(stateFromFacts(facts), operation).kind).toBe(expected[operation]);
@ -114,6 +140,7 @@ function stateFromFacts(facts: ActivePanelThreadFacts): ChatState {
facts.lifetime === "persistent"
? { kind: "persistent" }
: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: null },
canAcceptDirectInput: facts.canAcceptDirectInput,
provenance,
},
},

View file

@ -520,6 +520,31 @@ describe("submitComposer", () => {
expect(showLatest).not.toHaveBeenCalled();
expect(interruptTurn).toHaveBeenCalledWith("thread", "turn");
});
it("interrupts a running turn even when the thread rejects direct input", async () => {
const { host, interruptTurn, sendTurnText, stateStore } = createHost("unsent draft");
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { ...thread("thread"), canAcceptDirectInput: false },
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
await submitComposer(host);
expect(interruptTurn).toHaveBeenCalledWith("thread", "turn");
expect(sendTurnText).not.toHaveBeenCalled();
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
});
});
function resumeActiveThread(stateStore: ReturnType<typeof createChatStateStore>, id: string): void {

View file

@ -28,6 +28,7 @@ function thread(id: string): Thread {
updatedAt: 0,
name: null,
archived: false,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
};
}
@ -82,9 +83,10 @@ function resumeThread(stateStore: ReturnType<typeof createChatStateStore>, prese
});
}
function resumeSubagentThread(stateStore: ReturnType<typeof createChatStateStore>) {
function resumeSubagentThread(stateStore: ReturnType<typeof createChatStateStore>, canAcceptDirectInput: boolean | null = null) {
const child: Thread = {
...thread("child"),
canAcceptDirectInput,
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
@ -193,6 +195,41 @@ describe("TurnSubmissionActions", () => {
expect(host.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue.");
});
it("submits to a subagent when the loaded server capability allows direct input", async () => {
const { host, startTurn, stateStore } = createHost();
resumeSubagentThread(stateStore, true);
const actions = createTurnSubmissionActions(host);
await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(true);
expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ threadId: "child", input: textInput("hello") }));
expect(host.addSystemMessage).not.toHaveBeenCalled();
});
it("does not start a turn when the loaded thread rejects direct input", async () => {
const { host, startTurn, stateStore } = createHost();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { ...thread("thread"), canAcceptDirectInput: false },
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
const actions = createTurnSubmissionActions(host);
await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(false);
expect(startTurn).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith("This thread cannot accept messages.");
});
it("starts a side-chat turn when no pending runtime setting needs transport", async () => {
const { host, startTurn, stateStore } = createHost();
resumeSideChat(stateStore);

View file

@ -362,7 +362,8 @@ describe("ChatComposerController", () => {
composer(parent).setSelectionRange(1, 1);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(props.submissionDisabled).toBe(true);
expect(props.submissionDisabled).toBe(false);
expect(props.directInputDisabled).toBe(true);
expect(stateStore.getState().composer.suggestions).toEqual([]);
});

View file

@ -278,6 +278,7 @@ function shellParts(
busy: false,
canInterrupt: false,
submissionDisabled: false,
directInputDisabled: false,
runtimeControlsDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,

View file

@ -733,6 +733,28 @@ describe("chat panel surface projections", () => {
},
});
expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe("Ask in side chat...");
activeState = chatStateWith(activeState, { activeThread: { canAcceptDirectInput: false } });
expect(selectChatPanelComposer(activeState).submissionBlockedByPanelPolicy).toBe(true);
expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe(
"This thread cannot accept messages.",
);
const writableSubagentState = chatStateWith(chatStateFixture(), {
activeThread: {
id: "child",
canAcceptDirectInput: true,
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
},
});
expect(selectChatPanelComposer(writableSubagentState).submissionBlockedByPanelPolicy).toBe(false);
expect(composerProjectionFromState(composerProjectionActionsFixture(), writableSubagentState).placeholder).toBe("Ask Codex...");
expect(composerProjectionFromState(composerProjectionActionsFixture(), chatStateFixture()).placeholder).toBe("Ask Codex...");
});
@ -902,6 +924,7 @@ function threadFixture(id: string, name: string | null): Thread {
updatedAt: 1,
name,
archived: false,
canAcceptDirectInput: null,
provenance: { kind: "interactive" },
};
}

View file

@ -162,6 +162,7 @@ function shellParts(
busy: false,
canInterrupt: false,
submissionDisabled: false,
directInputDisabled: false,
runtimeControlsDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,

View file

@ -68,6 +68,7 @@ function panelThreadWithPatch(state: ChatState, patch: NonNullable<ChatStateFixt
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,
canAcceptDirectInput: patch.canAcceptDirectInput === undefined ? (current?.canAcceptDirectInput ?? null) : patch.canAcceptDirectInput,
provenance: patch.provenance === undefined ? (current?.provenance ?? null) : patch.provenance,
},
};

View file

@ -28,6 +28,7 @@ function mountComposerShell(
webSubmissionCancellable = webSubmissionPending,
sendDisabled = false,
runtimeControlsDisabled = false,
directInputDisabled = false,
): { composer: HTMLTextAreaElement } {
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
renderUiRoot(
@ -38,6 +39,7 @@ function mountComposerShell(
busy,
canInterrupt,
submissionDisabled: webSubmissionPending,
directInputDisabled,
runtimeControlsDisabled,
sendDisabled,
webSubmissionCancellable,
@ -446,6 +448,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
directInputDisabled: false,
runtimeControlsDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
@ -487,6 +490,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
directInputDisabled: false,
runtimeControlsDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
@ -603,6 +607,34 @@ describe("ComposerShell decisions", () => {
expect(sendButton?.disabled).toBe(true);
});
it("keeps interrupt available while direct input is disabled", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShell(
parent,
"view",
"unsent draft",
true,
true,
"This thread cannot accept messages.",
[],
0,
callbacks,
undefined,
false,
false,
false,
false,
true,
);
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(composer.readOnly).toBe(true);
expect(composer.getAttribute("placeholder")).toBe("This thread cannot accept messages.");
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
expect(sendButton?.disabled).toBe(false);
});
it("renders an enabled cancel control while a web import locks composer input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();

View file

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