mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
feat(chat): honor app-server direct input capability
This commit is contained in:
parent
73be2beea7
commit
01a20dcf70
30 changed files with 237 additions and 22 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: "参照元",
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -930,6 +930,7 @@ function thread(id: string, archived = false): Thread {
|
|||
archived,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" as const },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ function thread(
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
transcriptEntries: [],
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ function thread(overrides: Partial<Thread>): Thread {
|
|||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: "参照元",
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2148,6 +2148,7 @@ function panelThread(id: string): PanelThread {
|
|||
updatedAt: 0,
|
||||
name: null,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ describe("thread reference resolver", () => {
|
|||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
},
|
||||
"summarize",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ function shellParts(
|
|||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
directInputDisabled: false,
|
||||
runtimeControlsDisabled: false,
|
||||
sendDisabled: false,
|
||||
webSubmissionCancellable: false,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ function shellParts(
|
|||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
directInputDisabled: false,
|
||||
runtimeControlsDisabled: false,
|
||||
sendDisabled: false,
|
||||
webSubmissionCancellable: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export function thread(id: string): Thread {
|
|||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue