fix(web): make context imports safe and cancellable

This commit is contained in:
murashit 2026-07-14 21:15:21 +09:00
parent 2e97525d4f
commit e7ad2ca4c4
27 changed files with 726 additions and 64 deletions

View file

@ -4,11 +4,15 @@ export interface ChatPendingSubmissionState {
readonly id: string;
readonly item: ThreadStreamDialogueItem;
readonly targetThreadId: string | null;
readonly originalDraft: string;
readonly phase: "cancellable" | "committed";
}
export type PendingSubmissionAction =
| { type: "web-submission/pending"; submission: ChatPendingSubmissionState }
| { type: "web-submission/committed"; submissionId: string }
| { type: "web-submission/cancelled"; submissionId: string }
| { type: "web-submission/failed"; submissionId: string }
| { type: "web-submission/steer-adopted"; submissionId: string; item: ThreadStreamDialogueItem };
export function pendingSubmissionMatches(
@ -20,3 +24,13 @@ export function pendingSubmissionMatches(
): boolean {
return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThread.id;
}
export function cancellablePendingSubmissionMatches(
state: {
readonly pendingSubmission: ChatPendingSubmissionState | null;
readonly activeThread: { readonly id: string | null };
},
submissionId: string,
): boolean {
return pendingSubmissionMatches(state, submissionId) && state.pendingSubmission?.phase === "cancellable";
}

View file

@ -298,7 +298,9 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
case "turn/pending-start-hook-upserted":
case "request/resolved":
case "web-submission/pending":
case "web-submission/committed":
case "web-submission/cancelled":
case "web-submission/failed":
case "web-submission/steer-adopted":
return reduceChatTransition(state, action);
default:
@ -344,10 +346,20 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C
return reduceRequestResolvedTransition(state, action);
case "web-submission/pending":
return patchChatState(state, { pendingSubmission: action.submission });
case "web-submission/committed":
return state.pendingSubmission?.id === action.submissionId && state.pendingSubmission.phase === "cancellable"
? patchChatState(state, { pendingSubmission: { ...state.pendingSubmission, phase: "committed" } })
: state;
case "web-submission/cancelled":
return state.pendingSubmission?.id === action.submissionId ? patchChatState(state, { pendingSubmission: null }) : state;
return state.pendingSubmission?.id === action.submissionId && state.pendingSubmission.phase === "cancellable"
? patchChatState(state, { pendingSubmission: null })
: state;
case "web-submission/failed":
return state.pendingSubmission?.id === action.submissionId && state.pendingSubmission.phase === "committed"
? patchChatState(state, { pendingSubmission: null })
: state;
case "web-submission/steer-adopted":
if (state.pendingSubmission?.id !== action.submissionId) return state;
if (state.pendingSubmission?.id !== action.submissionId || state.pendingSubmission.phase !== "committed") return state;
return patchChatState(state, {
pendingSubmission: null,
threadStream: adoptPendingSteerItem(state.threadStream, action.item),
@ -508,7 +520,12 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc
}
function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOptimisticStartedAction): ChatState {
if (action.pendingSubmissionId && state.pendingSubmission?.id !== action.pendingSubmissionId) return state;
if (
action.pendingSubmissionId &&
(state.pendingSubmission?.id !== action.pendingSubmissionId || state.pendingSubmission.phase !== "committed")
) {
return state;
}
const lifecycle = transitionChatTurnLifecycleState(state.turn.lifecycle, {
type: "optimistic-started",
pendingTurnStart: action.pendingTurnStart,
@ -604,6 +621,10 @@ function clearConnectionScopedState(state: ChatState): ChatState {
},
threadList: initialThreadListState(),
pendingSubmission: null,
composer:
state.pendingSubmission?.phase === "cancellable"
? { ...initialComposerState(), draft: state.pendingSubmission.originalDraft }
: state.composer,
});
}

View file

@ -2,7 +2,7 @@ import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands";
import { parseSlashCommand } from "../composer/suggestions";
import type { LocalIdSource } from "../local-id-source";
import { pendingSubmissionMatches } from "../state/pending-submission";
import { cancellablePendingSubmissionMatches } from "../state/pending-submission";
import type { ChatStateStore } from "../state/store";
import { parseWebCommandArgs, type SlashCommandExecutionResult } from "./slash-command-execution";
import { submissionStateSnapshot } from "./submission-state";
@ -17,6 +17,7 @@ export interface ComposerSubmitActionsHost {
localItemIds: LocalIdSource;
ensureRestoredThreadLoaded?: () => Promise<boolean>;
composer: {
readonly draft: string;
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean; preserveContext?: boolean }): void;
captureInputSnapshot(): ComposerInputSnapshot;
@ -26,6 +27,7 @@ export interface ComposerSubmitActionsHost {
command: SlashCommandName,
args: string,
inputSnapshot: ComposerInputSnapshot,
isWebImportCurrent?: () => boolean,
): Promise<SlashCommandExecutionResult | undefined>;
};
turnSubmission: {
@ -49,7 +51,15 @@ export interface ComposerSubmitActions {
}
export async function submitComposer(host: ComposerSubmitActionsHost): Promise<void> {
const draft = host.composer.trimmedDraft;
const pendingSubmission = host.stateStore.getState().pendingSubmission;
if (pendingSubmission) {
if (pendingSubmission.phase === "cancellable") {
rollbackPendingWebSubmission(host, pendingSubmission.id, pendingSubmission.originalDraft);
}
return;
}
const originalDraft = host.composer.draft;
const draft = originalDraft.trim();
if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return;
const state = submissionStateSnapshot(host.stateStore.getState());
if (host.stateStore.getState().pendingSubmission) return;
@ -61,26 +71,31 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
await interruptTurn(host);
return;
}
await sendMessage(host, draft);
await sendMessage(host, draft, originalDraft);
}
async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promise<void> {
async function sendMessage(host: ComposerSubmitActionsHost, text: string, originalDraft: string): Promise<void> {
if (!text) return;
const inputSnapshot = host.composer.captureInputSnapshot();
const slashCommand = parseSlashCommand(text);
if (slashCommand) {
const pendingWeb = beginPendingWebSubmission(host, slashCommand.command, slashCommand.args);
if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.ensureConnected())) {
if (pendingWeb && pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, text);
return;
const pendingWeb = beginPendingWebSubmission(host, slashCommand.command, slashCommand.args, originalDraft);
if (slashCommandRequiresConnection(slashCommand.command)) {
const connected = await host.connection.ensureConnected();
if (!connected) {
if (pendingWeb && pendingWebSubmissionIsCurrent(host, pendingWeb.id))
rollbackPendingWebSubmission(host, pendingWeb.id, originalDraft);
return;
}
if (pendingWeb && !pendingWebSubmissionIsCurrent(host, pendingWeb.id)) return;
}
const execution = await executeSlashCommandAndRestoreOnFailure(
host,
slashCommand.command,
slashCommand.args,
inputSnapshot,
text,
originalDraft,
pendingWeb?.id,
);
if (execution.failed) return;
@ -97,19 +112,19 @@ async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promi
...(result.sendInput !== undefined ? { codexInputOverride: result.sendInput } : {}),
...(result.referencedThread !== undefined ? { referencedThread: result.referencedThread } : {}),
...(result.sendInput !== undefined ? { preserveComposerContextOnFailure: true } : {}),
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: text } : {}),
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: originalDraft } : {}),
});
if (!submitted) {
if (pendingWeb) {
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, text);
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, originalDraft);
} else {
host.composer.setDraft(text, { focus: true, clearSuggestions: true });
host.composer.setDraft(originalDraft, { focus: true, clearSuggestions: true });
}
}
}
if (result === undefined || (result.sendText === undefined && result.composerDraft === undefined)) {
if (pendingWeb) {
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, text);
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, originalDraft);
} else {
host.composer.setDraft("", { clearSuggestions: true });
}
@ -130,7 +145,14 @@ async function executeSlashCommandAndRestoreOnFailure(
pendingWebSubmissionId?: string,
): Promise<{ failed: false; result: SlashCommandExecutionResult | undefined } | { failed: true }> {
try {
return { failed: false, result: await host.slashCommandExecutor.execute(command, args, inputSnapshot) };
return {
failed: false,
result: pendingWebSubmissionId
? await host.slashCommandExecutor.execute(command, args, inputSnapshot, () =>
pendingWebSubmissionIsCurrent(host, pendingWebSubmissionId),
)
: await host.slashCommandExecutor.execute(command, args, inputSnapshot),
};
} catch (error) {
if (pendingWebSubmissionId && !pendingWebSubmissionIsCurrent(host, pendingWebSubmissionId)) return { failed: true };
if (pendingWebSubmissionId) cancelPendingWebSubmission(host, pendingWebSubmissionId);
@ -144,7 +166,12 @@ interface PendingWebSubmission {
id: string;
}
function beginPendingWebSubmission(host: ComposerSubmitActionsHost, command: SlashCommandName, args: string): PendingWebSubmission | null {
function beginPendingWebSubmission(
host: ComposerSubmitActionsHost,
command: SlashCommandName,
args: string,
originalDraft: string,
): PendingWebSubmission | null {
if (command !== "web") return null;
const parsed = parseWebCommandArgs(args);
if (!parsed) return null;
@ -154,7 +181,7 @@ function beginPendingWebSubmission(host: ComposerSubmitActionsHost, command: Sla
const activeThreadId = submissionStateSnapshot(host.stateStore.getState()).activeThreadId;
host.stateStore.dispatch({
type: "web-submission/pending",
submission: { id, item, targetThreadId: activeThreadId },
submission: { id, item, targetThreadId: activeThreadId, originalDraft, phase: "cancellable" },
});
host.composer.setDraft("", { clearSuggestions: true, preserveContext: true });
host.scroll.showLatest();
@ -162,7 +189,7 @@ function beginPendingWebSubmission(host: ComposerSubmitActionsHost, command: Sla
}
function pendingWebSubmissionIsCurrent(host: ComposerSubmitActionsHost, submissionId: string): boolean {
return pendingSubmissionMatches(host.stateStore.getState(), submissionId);
return cancellablePendingSubmissionMatches(host.stateStore.getState(), submissionId);
}
function cancelPendingWebSubmission(host: ComposerSubmitActionsHost, id: string): void {
@ -170,8 +197,9 @@ function cancelPendingWebSubmission(host: ComposerSubmitActionsHost, id: string)
}
function rollbackPendingWebSubmission(host: ComposerSubmitActionsHost, id: string, text: string): void {
if (!pendingWebSubmissionIsCurrent(host, id)) return;
cancelPendingWebSubmission(host, id);
host.composer.setDraft(text, { focus: true, clearSuggestions: true });
host.composer.setDraft(text, { focus: true, clearSuggestions: true, preserveContext: true });
}
async function interruptTurn(host: ComposerSubmitActionsHost): Promise<void> {

View file

@ -20,7 +20,7 @@ export interface TurnWorkflowContext {
connectionAvailable: () => boolean;
turnTransport: ChatTurnTransport;
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise<WebUrlInput>;
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
@ -45,6 +45,7 @@ export interface TurnWorkflowContext {
composer: {
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
captureInputSnapshot: () => ComposerInputSnapshot;
draft: () => string;
trimmedDraft: () => string;
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean; preserveContext?: boolean }) => void;
};
@ -143,6 +144,9 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
localItemIds,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
composer: {
get draft() {
return composer.draft();
},
get trimmedDraft() {
return composer.trimmedDraft();
},
@ -150,7 +154,8 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
captureInputSnapshot: composer.captureInputSnapshot,
},
slashCommandExecutor: {
execute: (command, args, inputSnapshot) => executeSlashCommandWithState(slashCommandExecutorHost, command, args, inputSnapshot),
execute: (command, args, inputSnapshot, isWebImportCurrent) =>
executeSlashCommandWithState(slashCommandExecutorHost, command, args, inputSnapshot, isWebImportCurrent),
},
turnSubmission,
connection: {

View file

@ -69,9 +69,10 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
activeThreadEphemeral: boolean;
listedThreads: readonly Thread[];
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise<WebUrlInput>;
supportedReasoningEfforts: () => readonly ReasoningEffort[];
inputSnapshot?: ComposerInputSnapshot;
isWebImportCurrent?: () => boolean;
}
export interface SlashCommandExecutionResult {
@ -155,7 +156,9 @@ export async function executeSlashCommand(
context.addSystemMessage("Cannot read a web URL without composer input context.");
return;
}
const web = await context.readWebUrl(parsed.url, parsed.message, context.inputSnapshot);
const web = context.isWebImportCurrent
? await context.readWebUrl(parsed.url, parsed.message, context.inputSnapshot, context.isWebImportCurrent)
: await context.readWebUrl(parsed.url, parsed.message, context.inputSnapshot);
return { sendText: web.text, sendInput: web.input };
}
case "fork":

View file

@ -20,7 +20,7 @@ export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts {
stateStore: ChatStateStore;
connectionAvailable: () => boolean;
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise<WebUrlInput>;
setStatus: (status: string) => void;
}
@ -29,6 +29,7 @@ export async function executeSlashCommandWithState(
command: SlashCommandName,
args: string,
inputSnapshot?: ComposerInputSnapshot,
isWebImportCurrent?: () => boolean,
): Promise<SlashCommandExecutionResult | undefined> {
const state = submissionStateSnapshot(host.stateStore.getState());
if (state.activeThreadSubagent) {
@ -43,6 +44,7 @@ export async function executeSlashCommandWithState(
referThread: host.referThread,
readWebUrl: host.readWebUrl,
...(inputSnapshot !== undefined ? { inputSnapshot } : {}),
...(isWebImportCurrent ? { isWebImportCurrent } : {}),
supportedReasoningEfforts: () => supportedReasoningEfforts(host.stateStore.getState()),
});
}

View file

@ -99,16 +99,27 @@ async function sendTurnText(
case "steer":
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request, referencedThread);
case "start-thread-then-turn":
if (!(await startThreadForTurn(host, prepared.text, request.pendingSubmissionId))) return false;
if (!commitPendingRequest(host, request)) return false;
if (!(await startThreadForTurn(host, prepared.text, request.pendingSubmissionId))) {
if (failPendingRequest(host, request)) restoreSubmittedDraft(host, text, request);
return false;
}
if (!pendingRequestIsCurrent(host, request)) return false;
break;
case "start-turn":
break;
}
const activeThreadId = plan.kind === "start-turn" ? plan.threadId : submissionStateSnapshot(host.stateStore.getState()).activeThreadId;
if (!activeThreadId) return false;
if (!activeThreadId) {
if (failPendingRequest(host, request)) restoreSubmittedDraft(host, text, request);
return false;
}
expectedThreadId = activeThreadId;
if (!(await host.applyPendingThreadSettings())) return false;
if (!commitPendingRequest(host, request)) return false;
if (!(await host.applyPendingThreadSettings())) {
if (failPendingRequest(host, request)) restoreSubmittedDraft(host, text, request);
return false;
}
if (!pendingRequestIsCurrent(host, request) || submissionStateSnapshot(host.stateStore.getState()).activeThreadId !== activeThreadId) {
return false;
}
@ -184,6 +195,7 @@ async function sendTurnText(
pendingTurnStart: failedState.pendingTurnStart,
});
host.stateStore.dispatch({ type: "turn/start-failed", items });
failPendingRequest(host, request);
restoreSubmittedDraft(host, text, request);
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -223,6 +235,7 @@ async function steerCurrentTurn(
referencedThread?: ReferencedThreadMetadata,
): Promise<boolean> {
if (!pendingRequestIsCurrent(host, request)) return false;
if (!commitPendingRequest(host, request)) return false;
const localSteerId = localItemIds.next("local-steer");
clearDraftForSubmission(host, request, { clearSuggestions: true });
@ -234,7 +247,10 @@ async function steerCurrentTurn(
clientUserMessageId: localSteerId,
});
if (!steered) {
if (pendingRequestIsCurrent(host, request)) restoreSubmittedDraft(host, text, request, { focus: true });
if (pendingRequestIsCurrent(host, request)) {
failPendingRequest(host, request);
restoreSubmittedDraft(host, text, request, { focus: true });
}
return false;
}
const currentTurn = isCurrentTurn(host, plan.threadId, plan.turnId);
@ -260,6 +276,7 @@ async function steerCurrentTurn(
isCurrentTurn(host, plan.threadId, plan.turnId) ||
(request.pendingSubmissionId !== undefined && pendingRequestIsCurrent(host, request))
) {
failPendingRequest(host, request);
restoreSubmittedDraft(host, text, request, { focus: true });
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -311,3 +328,16 @@ function isCurrentTurn(host: TurnSubmissionActionsHost, threadId: string, turnId
function pendingRequestIsCurrent(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
return request.pendingSubmissionId ? pendingSubmissionMatches(host.stateStore.getState(), request.pendingSubmissionId) : true;
}
function commitPendingRequest(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
if (!request.pendingSubmissionId) return true;
if (!pendingRequestIsCurrent(host, request)) return false;
host.stateStore.dispatch({ type: "web-submission/committed", submissionId: request.pendingSubmissionId });
return pendingRequestIsCurrent(host, request) && host.stateStore.getState().pendingSubmission?.phase === "committed";
}
function failPendingRequest(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
if (!request.pendingSubmissionId || !pendingRequestIsCurrent(host, request)) return false;
host.stateStore.dispatch({ type: "web-submission/failed", submissionId: request.pendingSubmissionId });
return true;
}

View file

@ -21,6 +21,7 @@ export function normalizedHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;

View file

@ -99,10 +99,11 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
connectionAvailable: () => appServer.connectionAvailable(),
turnTransport: appServer.turn,
referThread: (thread, message, snapshot) => threadReferenceResolver.referThread(thread, message, snapshot),
readWebUrl: (url, message, snapshot) =>
readWebUrl: (url, message, snapshot, isCurrent) =>
createWebContextReader({
prepareInput: (text, inputSnapshot) => composerController.preparedInput(text, inputSnapshot),
viewWindow: host.environment.view.viewWindow,
...(isCurrent ? { isCurrent } : {}),
}).readUrl(url, message, snapshot),
status,
runtime: {
@ -136,6 +137,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
composer: {
prepareInput: (text, snapshot) => composerController.preparedInput(text, snapshot),
captureInputSnapshot: () => composerController.captureInputSnapshot(),
draft: () => composerController.draft,
trimmedDraft: () => composerController.trimmedDraft,
setDraft: (text, options) => {
composerController.setDraft(text, options);

View file

@ -16,6 +16,7 @@ interface WebContextReaderOptions {
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
viewWindow: () => Window | null;
requestTimeoutMs?: number;
isCurrent?: () => boolean;
}
type DomParserWindow = Window & { DOMParser: typeof DOMParser };
@ -35,9 +36,11 @@ async function readUrlToInput(
): Promise<WebUrlInput> {
const parsedUrl = normalizedHttpUrl(url);
if (!parsedUrl) throw new Error(`Unsupported web URL: ${url}`);
assertCurrentWebImport(options);
const page = await fetchWebPage(options, parsedUrl);
const content = htmlToMarkdown(page.content).trim();
assertCurrentWebImport(options);
if (!content) throw new Error(`No readable web content found for ${parsedUrl}`);
const messageInput = options.prepareInput(message.trim(), inputSnapshot);
@ -54,6 +57,7 @@ async function readUrlToInput(
async function fetchWebPage(options: WebContextReaderOptions, url: string): Promise<{ title: string; content: string }> {
const response = await requestWebPage(options, url, options.requestTimeoutMs ?? 30_000);
assertCurrentWebImport(options);
if (response.status < 200 || response.status >= 300) {
throw new Error(`Web request failed for ${url} (HTTP ${String(response.status)}).`);
}
@ -64,6 +68,10 @@ async function fetchWebPage(options: WebContextReaderOptions, url: string): Prom
return { title: result.title, content: result.content };
}
function assertCurrentWebImport(options: WebContextReaderOptions): void {
if (options.isCurrent?.() === false) throw new Error("Web import cancelled.");
}
function requestWebPage(options: WebContextReaderOptions, url: string, timeoutMs: number): Promise<RequestUrlResponse> {
const timerHost = options.viewWindow();
if (!timerHost) throw new Error(`Web request unavailable for ${url}.`);

View file

@ -92,8 +92,12 @@ export class ChatComposerController {
this.options.stateStore.dispatch(action);
}
get draft(): string {
return this.composer?.value ?? this.state.composer.draft;
}
get trimmedDraft(): string {
return this.composer?.value.trim() ?? this.state.composer.draft.trim();
return this.draft.trim();
}
renderState(model: ChatPanelComposerReadModel, actions: ChatComposerRenderActions): ComposerShellProps {
@ -104,6 +108,7 @@ export class ChatComposerController {
busy: model.turnBusy.value,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.activeThreadSubagent.value || model.webSubmissionPending.value,
webSubmissionCancellable: model.webSubmissionCancellable.value,
normalPlaceholder: projection.placeholder,
suggestions: model.suggestions.value,
selectedSuggestionIndex: model.selectedSuggestionIndex.value,
@ -520,6 +525,11 @@ export class ChatComposerController {
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
},
onSendOrInterrupt: () => {
if (this.state.pendingSubmission?.phase === "cancellable") {
actions.submit();
return;
}
if (this.state.pendingSubmission) return;
void this.submitAfterAttachmentTransfers(actions);
},
onHeightChange: () => {

View file

@ -50,6 +50,7 @@ interface ChatPanelShellSignals {
threadStreamForkCandidates: ReadonlySignal<readonly ForkCandidate[]>;
threadStreamImplementPlanTarget: ReadonlySignal<PlanImplementationTarget | null>;
webSubmissionPending: ReadonlySignal<boolean>;
webSubmissionCancellable: ReadonlySignal<boolean>;
threadStreamDisclosures: ReadonlySignal<ChatPanelThreadStreamDisclosureState>;
threadStreamForkMenuItemId: ReadonlySignal<ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"]>;
hasThreadTurns: ReadonlySignal<boolean>;
@ -143,6 +144,7 @@ export interface ChatPanelComposerReadModel {
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly webSubmissionPending: ReadonlySignal<boolean>;
readonly webSubmissionCancellable: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly activeTurnId: ReadonlySignal<string | null>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
@ -213,6 +215,7 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
}),
),
webSubmissionPending: computed(() => pendingSubmission.value !== null),
webSubmissionCancellable: computed(() => pendingSubmission.value?.phase === "cancellable"),
threadStreamDisclosures: createThreadStreamDisclosuresSignal(ui),
threadStreamForkMenuItemId: computed(() => ui.value.threadStreamActionMenu.forkMenuItemId),
hasThreadTurns,
@ -364,6 +367,7 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
webSubmissionPending: signals.webSubmissionPending,
webSubmissionCancellable: signals.webSubmissionCancellable,
turnBusy: signals.turnBusy,
activeTurnId: signals.activeTurnId,
runtimeSnapshot: signals.composerRuntimeSnapshot,

View file

@ -28,7 +28,7 @@ function accessRows(profile: string | null, sandbox: RuntimeSandboxPolicy | null
return [
{ label: "Profile", value: profileLabel(profile, sandbox) },
{ label: "Sandbox", value: sandboxLabel(sandbox) },
{ label: "Network", value: networkLabel(sandbox) },
{ label: "Codex network", value: networkLabel(sandbox) },
{ label: "Extra writable roots", value: writableRootsLabel(sandbox, vaultPath) },
];
}

View file

@ -89,6 +89,7 @@ export interface ComposerShellProps {
busy: boolean;
canInterrupt: boolean;
submissionDisabled: boolean;
webSubmissionCancellable: boolean;
normalPlaceholder: string;
meta: ComposerMetaViewModel;
suggestions: readonly ComposerSuggestion[];
@ -105,6 +106,7 @@ export function ComposerShell({
busy,
canInterrupt,
submissionDisabled,
webSubmissionCancellable,
normalPlaceholder,
meta,
suggestions,
@ -149,7 +151,8 @@ export function ComposerShell({
if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor);
onPendingSelectionApplied?.();
}, [draft, pendingSelection, onPendingSelectionApplied]);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, webSubmissionCancellable);
const composerLocked = submissionDisabled;
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
@ -166,7 +169,7 @@ export function ComposerShell({
aria-controls={composerSuggestionsListId(viewId)}
aria-activedescendant={selectedSuggestionId}
value={draft}
readOnly={submissionDisabled}
readOnly={composerLocked}
onInput={(event) => {
if (syncComposerHeight(event.currentTarget)) callbacks.onHeightChange();
callbacks.onInput(event.currentTarget.value);
@ -187,7 +190,7 @@ export function ComposerShell({
callbacks.onDragOver?.(event);
}}
/>
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={submissionDisabled} />
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={composerLocked} />
</div>
<ComposerSuggestions
containerRef={suggestionsRef}
@ -471,7 +474,22 @@ interface ComposerSendMode {
canInterrupt: boolean;
}
function composerSendMode(busy: boolean, canInterrupt: boolean, draft: string, submissionDisabled: boolean): ComposerSendMode {
function composerSendMode(
busy: boolean,
canInterrupt: boolean,
draft: string,
submissionDisabled: boolean,
webSubmissionCancellable: boolean,
): ComposerSendMode {
if (webSubmissionCancellable) {
return {
icon: "square",
label: "Cancel web import",
className: "is-interrupt",
disabled: false,
canInterrupt: false,
};
}
const hasDraft = Boolean(draft.trim());
const canSteer = canInterrupt && hasDraft;
const interruptMode = canInterrupt && !hasDraft;

View file

@ -6,12 +6,56 @@ import { type ChatState, chatReducer } from "../../../../../src/features/chat/ap
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream";
import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
import { chatStateFixture, chatStateWith } from "../../support/state";
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
describe("chatReducer", () => {
it.each([
"connection/scoped-cleared",
"connection/context-replaced",
] as const)("restores cancellable web drafts when %s clears their context", (type) => {
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const state = chatReducer(chatReducer(chatStateFixture(), { type: "composer/draft-set", draft: "" }), {
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: " /web https://example.com summarize ",
phase: "cancellable",
},
} as never);
const cleared = chatReducer(state, { type });
expect(cleared.pendingSubmission).toBeNull();
expect(cleared.composer.draft).toBe(" /web https://example.com summarize ");
});
it("does not restore committed web drafts when their connection context is replaced", () => {
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const state = chatReducer(chatStateFixture(), {
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "committed",
},
} as never);
const cleared = chatReducer(state, { type: "connection/context-replaced" });
expect(cleared.pendingSubmission).toBeNull();
expect(cleared.composer.draft).toBe("");
});
it("clears connection metadata only when the app-server context is replaced", () => {
const state = chatStateWith(chatStateFixture(), {
connection: {

View file

@ -98,7 +98,13 @@ describe("thread start actions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: null },
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createThreadStartActions({
stateStore,
@ -119,7 +125,13 @@ describe("thread start actions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: null },
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const started = deferred<ThreadActivationSnapshot | null>();
const recordStartedThread = vi.fn();

View file

@ -54,9 +54,12 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) {
stateStore,
localItemIds: createLocalIdSource(),
composer: {
get trimmedDraft() {
get draft() {
return draft;
},
get trimmedDraft() {
return draft.trim();
},
setDraft,
captureInputSnapshot,
},
@ -87,6 +90,30 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) {
}
describe("submitComposer", () => {
it("does not cancel or restore a committed web submission", async () => {
const { host, execute, setDraft, stateStore } = createHost("");
const pending = {
id: "local-web",
item: {
id: "local-web",
kind: "dialogue" as const,
dialogueKind: "user" as const,
role: "user" as const,
text: "https://example.com/ summarize",
},
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "committed" as const,
};
stateStore.dispatch({ type: "web-submission/pending", submission: pending } as never);
await submitComposer(host);
expect(stateStore.getState().pendingSubmission).toEqual(pending);
expect(setDraft).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
});
it.each(["hello", "/status"])("blocks composer submission from subagent threads for %s", async (draft) => {
const { host, execute, sendTurnText } = createHost(draft, { subagent: true });
@ -177,7 +204,11 @@ describe("submitComposer", () => {
pendingSubmissionId: expect.stringMatching(/^local-web-/),
failureDraft: "/web https://example.com [[Note]]",
});
expect(setDraft).toHaveBeenCalledWith("/web https://example.com [[Note]]", { focus: true, clearSuggestions: true });
expect(setDraft).toHaveBeenCalledWith("/web https://example.com [[Note]]", {
focus: true,
clearSuggestions: true,
preserveContext: true,
});
});
it("shows a pending web message while context is fetched and hands it to turn submission", async () => {
@ -217,18 +248,74 @@ describe("submitComposer", () => {
});
});
it("prevents a second submission while web context is being fetched", async () => {
const { host, execute } = createHost("/web https://example.com summarize");
it("cancels a pending web import and restores its exact draft before late success", async () => {
const { host, execute, sendTurnText, setDraft, stateStore } = createHost(" /web https://example.com summarize ");
const fetch = deferred<{ sendText: string }>();
execute.mockImplementation(() => fetch.promise);
const first = submitComposer(host);
await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(stateStore.getState().pendingSubmission).not.toBeNull());
await submitComposer(host);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(setDraft.mock.calls.at(-1)).toEqual([
" /web https://example.com summarize ",
{ focus: true, clearSuggestions: true, preserveContext: true },
]);
expect(execute).toHaveBeenCalledOnce();
fetch.resolve({ sendText: "https://example.com/ summarize" });
await first;
expect(sendTurnText).not.toHaveBeenCalled();
});
it("ignores a late web import failure after explicit cancellation", async () => {
const { host, execute, setDraft, stateStore } = createHost("/web https://example.com summarize");
const fetch = deferred<{ sendText: string }>();
execute.mockImplementation(() => fetch.promise);
const first = submitComposer(host);
await vi.waitFor(() => expect(stateStore.getState().pendingSubmission).not.toBeNull());
await submitComposer(host);
fetch.reject(new Error("offline"));
await first;
expect(setDraft.mock.calls).toEqual([
["", { clearSuggestions: true, preserveContext: true }],
["/web https://example.com summarize", { focus: true, clearSuggestions: true, preserveContext: true }],
]);
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
});
it.each([true, false])("ignores late connection result %s after explicit web cancellation", async (connected) => {
const { host, ensureConnected, execute, setDraft, stateStore } = createHost("/web https://example.com summarize");
const connecting = deferred<boolean>();
ensureConnected.mockImplementation(() => connecting.promise);
const first = submitComposer(host);
await vi.waitFor(() => expect(stateStore.getState().pendingSubmission).not.toBeNull());
await submitComposer(host);
connecting.resolve(connected);
await first;
expect(execute).not.toHaveBeenCalled();
expect(setDraft.mock.calls.at(-1)).toEqual([
"/web https://example.com summarize",
{ focus: true, clearSuggestions: true, preserveContext: true },
]);
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
});
it("does not create pending UI for a web URL containing credentials", async () => {
const { host, execute, stateStore } = createHost("/web https://user:secret@example.com/article summarize");
const reading = deferred<undefined>();
execute.mockImplementation(() => reading.promise);
const submitting = submitComposer(host);
await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
expect(stateStore.getState().pendingSubmission).toBeNull();
reading.resolve(undefined);
await submitting;
});
it("drops a pending web submission when the active thread changes during fetch", async () => {
@ -279,6 +366,30 @@ describe("submitComposer", () => {
expect(stateStore.getState().pendingSubmission).toBeNull();
});
it.each([
"connection/scoped-cleared",
"connection/context-replaced",
] as const)("recovers the web draft and ignores late fetch success after %s", async (type) => {
const { host, execute, sendTurnText, setDraft, stateStore } = createHost(" /web https://example.com summarize ");
const fetch = deferred<{ sendText: string }>();
execute.mockImplementation(() => fetch.promise);
const submitting = submitComposer(host);
await vi.waitFor(() => expect(stateStore.getState().pendingSubmission).not.toBeNull());
stateStore.dispatch({ type });
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(stateStore.getState().composer.draft).toBe(" /web https://example.com summarize ");
fetch.resolve({ sendText: "https://example.com/ summarize" });
await submitting;
expect(sendTurnText).not.toHaveBeenCalled();
expect(setDraft.mock.calls).toEqual([["", { clearSuggestions: true, preserveContext: true }]]);
expect(stateStore.getState().composer.draft).toBe(" /web https://example.com summarize ");
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
});
it("does not execute connection-dependent slash commands when connection fails", async () => {
const { host, ensureConnected, execute, setDraft } = createHost("/clear");
ensureConnected.mockResolvedValue(false);
@ -301,7 +412,7 @@ describe("submitComposer", () => {
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(setDraft.mock.calls).toEqual([
["", { clearSuggestions: true, preserveContext: true }],
["/web https://example.com summarize", { focus: true, clearSuggestions: true }],
["/web https://example.com summarize", { focus: true, clearSuggestions: true, preserveContext: true }],
]);
});

View file

@ -113,6 +113,7 @@ describe("createTurnWorkflowActions", () => {
composer: {
prepareInput,
captureInputSnapshot: vi.fn(() => composerSnapshot),
draft: () => "",
trimmedDraft: () => "",
setDraft: vi.fn(),
},

View file

@ -174,7 +174,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: null },
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -200,7 +206,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: null },
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -212,7 +224,11 @@ describe("TurnSubmissionActions", () => {
await vi.waitFor(() => expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce());
expect(stateStore.getState().activeThread.id).toBe("thread");
expect(stateStore.getState().pendingSubmission).toMatchObject({ id: pending.id, targetThreadId: "thread" });
expect(stateStore.getState().pendingSubmission).toMatchObject({
id: pending.id,
targetThreadId: "thread",
phase: "committed",
});
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
settings.resolve(true);
@ -225,6 +241,183 @@ describe("TurnSubmissionActions", () => {
});
});
it("commits a pending web import before thread creation and ignores cancellation during the RPC", async () => {
const threadStarting = deferred<void>();
const { host, startTurn, stateStore } = createHost();
host.startThread = vi.fn().mockImplementation(async (_preview, options) => {
await threadStarting.promise;
resumeThread(stateStore, options?.preservePendingSubmissionId);
return true;
});
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
} as never);
const actions = createTurnSubmissionActions(host);
const submitting = actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
});
await vi.waitFor(() => expect(host.startThread).toHaveBeenCalledOnce());
expect(stateStore.getState().pendingSubmission?.phase).toBe("committed");
stateStore.dispatch({ type: "web-submission/cancelled", submissionId: pending.id });
expect(stateStore.getState().pendingSubmission?.phase).toBe("committed");
threadStarting.resolve(undefined);
await expect(submitting).resolves.toBe(true);
expect(startTurn).toHaveBeenCalledOnce();
expect(stateStore.getState().pendingSubmission).toBeNull();
});
it("cleans up a committed web import when pending runtime settings fail", async () => {
const { host, startTurn, stateStore } = createHost({ applyPendingThreadSettings: vi.fn().mockResolvedValue(false) });
resumeThread(stateStore);
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
await expect(
actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
}),
).resolves.toBe(false);
expect(startTurn).not.toHaveBeenCalled();
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(host.setDraft).toHaveBeenCalledWith("/web https://example.com summarize", { preserveContext: true });
});
it("cleans up a committed web import when starting the turn fails", async () => {
const { host, startTurn, stateStore } = createHost();
startTurn.mockResolvedValue(null);
resumeThread(stateStore);
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
await expect(
actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
}),
).resolves.toBe(false);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(host.setDraft).toHaveBeenCalledWith("/web https://example.com summarize", { preserveContext: true });
});
it("commits a pending web import before steering and lets the late result adopt it", async () => {
const steering = deferred<boolean>();
const { host, stateStore, steerTurn } = createHost();
resumeThread(stateStore);
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
steerTurn.mockImplementation(() => steering.promise);
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
} as never);
const actions = createTurnSubmissionActions(host);
const submitting = actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
});
await vi.waitFor(() => expect(steerTurn).toHaveBeenCalledOnce());
expect(stateStore.getState().pendingSubmission?.phase).toBe("committed");
stateStore.dispatch({ type: "web-submission/cancelled", submissionId: pending.id });
expect(stateStore.getState().pendingSubmission?.phase).toBe("committed");
steering.resolve(true);
await expect(submitting).resolves.toBe(true);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(chatStateThreadStreamItems(stateStore.getState())[0]).toMatchObject({ id: pending.id, turnId: "turn" });
});
it("cleans up and restores a committed pending web steer when the RPC fails", async () => {
const { host, stateStore, steerTurn } = createHost();
resumeThread(stateStore);
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
steerTurn.mockResolvedValue(false);
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
} as never);
const actions = createTurnSubmissionActions(host);
await expect(
actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
}),
).resolves.toBe(false);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(host.setDraft).toHaveBeenCalledWith("/web https://example.com summarize", { focus: true, preserveContext: true });
});
it("restores the original web command when starting the adopted turn returns no response", async () => {
const { host, startTurn, stateStore } = createHost();
resumeThread(stateStore);
@ -233,7 +426,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -260,7 +459,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -288,7 +493,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -445,7 +656,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const actions = createTurnSubmissionActions(host);
@ -473,7 +690,13 @@ describe("TurnSubmissionActions", () => {
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
const steering = deferred<boolean>();
steerTurn.mockImplementation(() => steering.promise);

View file

@ -75,6 +75,9 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
selectThread: vi.fn(),
},
composerController: {
get draft() {
return "/tools";
},
get trimmedDraft() {
return "/tools";
},

View file

@ -112,6 +112,48 @@ describe("web context reader", () => {
expect(mocks.requestUrl).not.toHaveBeenCalled();
});
it("rejects URLs containing credentials before fetching", async () => {
await expect(
createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: fakeDomWindow,
}).readUrl("https://user:secret@example.com/article", "", {} as ComposerInputSnapshot),
).rejects.toThrow("Unsupported web URL: https://user:secret@example.com/article");
expect(mocks.requestUrl).not.toHaveBeenCalled();
});
it("stops before DOM parsing when the import is cancelled after the response", async () => {
const isCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValue(false);
await expect(
createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: fakeDomWindow,
isCurrent,
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot),
).rejects.toThrow("Web import cancelled.");
expect(mocks.defuddleParse).not.toHaveBeenCalled();
expect(mocks.htmlToMarkdown).not.toHaveBeenCalled();
});
it("stops after conversion when the import is cancelled during processing", async () => {
const prepareInput = vi.fn(() => ({ text: "", input: [{ type: "text" as const, text: "" }] }));
const isCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValue(false);
await expect(
createWebContextReader({ prepareInput, viewWindow: fakeDomWindow, isCurrent }).readUrl(
"https://example.com/article",
"",
{} as ComposerInputSnapshot,
),
).rejects.toThrow("Web import cancelled.");
expect(mocks.htmlToMarkdown).toHaveBeenCalledOnce();
expect(prepareInput).not.toHaveBeenCalled();
});
it("rejects web requests that do not respond before the timeout", async () => {
vi.useFakeTimers();
try {

View file

@ -84,18 +84,46 @@ function composerControllerFixture(
}
describe("ChatComposerController", () => {
it("disables composer submission while web context is being fetched", () => {
it("locks composer input while exposing a pending web import to the send control", () => {
const { controller, stateStore } = composerControllerFixture();
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: null },
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "cancellable",
},
});
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.submissionDisabled).toBe(true);
expect(props.webSubmissionCancellable).toBe(true);
});
it("locks composer input without offering cancel after a web submission commits", () => {
const { controller, stateStore } = composerControllerFixture();
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "committed",
},
} as never);
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.submissionDisabled).toBe(true);
expect(props.webSubmissionCancellable).toBe(false);
});
it("keeps a pending web submission after a turn that completes during the fetch", () => {
@ -114,7 +142,13 @@ describe("ChatComposerController", () => {
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com",
phase: "cancellable",
},
});
stateStore.dispatch({ type: "turn/completed", turnId: "turn", status: "completed", items: [assistant] });
@ -355,7 +389,7 @@ describe("ChatComposerController", () => {
]);
});
it("preserves pasted image attachments across temporary slash command draft clears", async () => {
it("preserves pasted image attachments when connection exit restores a cancellable web draft", async () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const attachmentHandler: ComposerAttachmentHandler = {
@ -400,11 +434,26 @@ describe("ChatComposerController", () => {
await flushComposerAttachment();
const marker = composer(parent).value;
const snapshot = controller.captureInputSnapshot();
const originalDraft = `/web https://example.com Inspect ${marker}`;
const pending = pendingWebSubmissionItem("local-web", "https://example.com", `Inspect ${marker}`);
if (!pending) throw new Error("Expected pending web submission");
controller.setDraft(originalDraft);
stateStore.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft,
phase: "cancellable",
},
});
controller.setDraft("", { clearSuggestions: true, preserveContext: true });
controller.setDraft(`Inspect ${marker}`);
stateStore.dispatch({ type: "connection/scoped-cleared" });
const restoredSnapshot = controller.captureInputSnapshot();
expect(controller.draft).toBe(originalDraft);
expect(controller.preparedInput(`Inspect ${marker}`, snapshot).input).toEqual([
{ type: "text", text: `Inspect ${marker}` },
{ type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" },

View file

@ -93,7 +93,7 @@ describe("createChatPanelRuntimeProjection", () => {
auditFacts: [
{ key: "Profile", value: "workspace-write" },
{ key: "Sandbox", value: "workspace-write" },
{ key: "Network", value: "blocked" },
{ key: "Codex network", value: "blocked" },
{ key: "Extra writable roots", value: "Vault/Notes" },
],
},
@ -136,7 +136,7 @@ describe("createChatPanelRuntimeProjection", () => {
auditFacts: [
{ key: "Profile", value: "(not reported)" },
{ key: "Sandbox", value: "(not reported)" },
{ key: "Network", value: "(not reported)" },
{ key: "Codex network", value: "(not reported)" },
{ key: "Extra writable roots", value: "(not reported)" },
],
},
@ -169,7 +169,7 @@ describe("createChatPanelRuntimeProjection", () => {
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
{ key: "Profile", value: ":workspace" },
{ key: "Sandbox", value: "(not reported)" },
{ key: "Network", value: "(not reported)" },
{ key: "Codex network", value: "(not reported)" },
{ key: "Extra writable roots", value: "(not reported)" },
]);
});
@ -200,7 +200,7 @@ describe("createChatPanelRuntimeProjection", () => {
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
{ key: "Profile", value: "DevProfile" },
{ key: "Sandbox", value: "(not reported)" },
{ key: "Network", value: "(not reported)" },
{ key: "Codex network", value: "(not reported)" },
{ key: "Extra writable roots", value: "(not reported)" },
]);
});

View file

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

View file

@ -124,6 +124,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
busy: false,
canInterrupt: false,
submissionDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,

View file

@ -22,6 +22,8 @@ function mountComposerShell(
selectedSuggestionIndex: number,
callbacks: ComposerCallbacks,
meta?: ComposerMetaViewModel,
webSubmissionPending = false,
webSubmissionCancellable = webSubmissionPending,
): { composer: HTMLTextAreaElement } {
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
renderUiRoot(
@ -31,7 +33,8 @@ function mountComposerShell(
draft,
busy,
canInterrupt,
submissionDisabled: false,
submissionDisabled: webSubmissionPending,
webSubmissionCancellable,
normalPlaceholder,
suggestions,
selectedSuggestionIndex,
@ -415,6 +418,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,
@ -453,6 +457,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
selectedSuggestionIndex: 0,
@ -552,6 +557,30 @@ describe("ComposerShell decisions", () => {
expect(sendButton?.dataset["icon"]).toBe("corner-down-right");
});
it("renders an enabled cancel control while a web import locks composer input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks, undefined, true);
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(composer.readOnly).toBe(true);
expect(sendButton?.getAttribute("aria-label")).toBe("Cancel web import");
expect(sendButton?.disabled).toBe(false);
sendButton?.click();
expect(callbacks.onSendOrInterrupt).toHaveBeenCalledOnce();
});
it("keeps composer locked without offering cancel after a web import commits", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks, undefined, true, false);
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(composer.readOnly).toBe(true);
expect(sendButton?.getAttribute("aria-label")).toBe("Send");
expect(sendButton?.disabled).toBe(true);
});
it("honors the smaller viewport branch of the composer max-height CSS", () => {
const composer = document.createElement("textarea");
const getComputedStyleMock = vi.spyOn(window, "getComputedStyle").mockReturnValue({

View file

@ -207,7 +207,7 @@ describe("Toolbar decisions", () => {
rows: [
{ label: "Profile", value: ":workspace" },
{ label: "Sandbox", value: "workspace-write" },
{ label: "Network", value: "blocked" },
{ label: "Codex network", value: "blocked" },
{ label: "Extra writable roots", value: "Vault" },
],
},