diff --git a/src/features/chat/application/state/pending-submission.ts b/src/features/chat/application/state/pending-submission.ts index 53024abe..a2241a3c 100644 --- a/src/features/chat/application/state/pending-submission.ts +++ b/src/features/chat/application/state/pending-submission.ts @@ -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"; +} diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 2503a4ab..2f2e157c 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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, }); } diff --git a/src/features/chat/application/turns/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts index e1386176..3b4cb73f 100644 --- a/src/features/chat/application/turns/composer-submit-actions.ts +++ b/src/features/chat/application/turns/composer-submit-actions.ts @@ -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; 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; }; turnSubmission: { @@ -49,7 +51,15 @@ export interface ComposerSubmitActions { } export async function submitComposer(host: ComposerSubmitActionsHost): Promise { - 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 { +async function sendMessage(host: ComposerSubmitActionsHost, text: string, originalDraft: string): Promise { 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 { diff --git a/src/features/chat/application/turns/composition.ts b/src/features/chat/application/turns/composition.ts index 8969df8d..cef54b93 100644 --- a/src/features/chat/application/turns/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -20,7 +20,7 @@ export interface TurnWorkflowContext { connectionAvailable: () => boolean; turnTransport: ChatTurnTransport; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise; 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: { diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index 61e5d00b..13b5bc05 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -69,9 +69,10 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts activeThreadEphemeral: boolean; listedThreads: readonly Thread[]; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise; 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": diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index a033e5a6..e1507d76 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -20,7 +20,7 @@ export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts { stateStore: ChatStateStore; connectionAvailable: () => boolean; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise; setStatus: (status: string) => void; } @@ -29,6 +29,7 @@ export async function executeSlashCommandWithState( command: SlashCommandName, args: string, inputSnapshot?: ComposerInputSnapshot, + isWebImportCurrent?: () => boolean, ): Promise { 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()), }); } diff --git a/src/features/chat/application/turns/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts index 927d2693..af6a9cce 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -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 { 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; +} diff --git a/src/features/chat/application/turns/web-submission.ts b/src/features/chat/application/turns/web-submission.ts index 7e6279a2..c9ac649e 100644 --- a/src/features/chat/application/turns/web-submission.ts +++ b/src/features/chat/application/turns/web-submission.ts @@ -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; diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index c2535221..6c8d098e 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -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); diff --git a/src/features/chat/host/obsidian/web-context.obsidian.ts b/src/features/chat/host/obsidian/web-context.obsidian.ts index e87dc264..b5dc8aac 100644 --- a/src/features/chat/host/obsidian/web-context.obsidian.ts +++ b/src/features/chat/host/obsidian/web-context.obsidian.ts @@ -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 { 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 { const timerHost = options.viewWindow(); if (!timerHost) throw new Error(`Web request unavailable for ${url}.`); diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index fb00b37f..f17ac6ba 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -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: () => { diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index 9f3ca304..b62ffa77 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -50,6 +50,7 @@ interface ChatPanelShellSignals { threadStreamForkCandidates: ReadonlySignal; threadStreamImplementPlanTarget: ReadonlySignal; webSubmissionPending: ReadonlySignal; + webSubmissionCancellable: ReadonlySignal; threadStreamDisclosures: ReadonlySignal; threadStreamForkMenuItemId: ReadonlySignal; hasThreadTurns: ReadonlySignal; @@ -143,6 +144,7 @@ export interface ChatPanelComposerReadModel { readonly activeThreadId: ReadonlySignal; readonly activeThreadSubagent: ReadonlySignal; readonly webSubmissionPending: ReadonlySignal; + readonly webSubmissionCancellable: ReadonlySignal; readonly turnBusy: ReadonlySignal; readonly activeTurnId: ReadonlySignal; readonly runtimeSnapshot: ReadonlySignal; @@ -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, diff --git a/src/features/chat/presentation/runtime/permission-sections.ts b/src/features/chat/presentation/runtime/permission-sections.ts index 96e19285..0c4914f0 100644 --- a/src/features/chat/presentation/runtime/permission-sections.ts +++ b/src/features/chat/presentation/runtime/permission-sections.ts @@ -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) }, ]; } diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 2b5a188b..259428ec 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -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); }} /> - + { + 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: { diff --git a/tests/features/chat/application/threads/thread-start-actions.test.ts b/tests/features/chat/application/threads/thread-start-actions.test.ts index b143818d..ae34fc0b 100644 --- a/tests/features/chat/application/threads/thread-start-actions.test.ts +++ b/tests/features/chat/application/threads/thread-start-actions.test.ts @@ -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(); const recordStartedThread = vi.fn(); diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index f7ef9f70..820d8c87 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -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(); + 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(); + 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 }], ]); }); diff --git a/tests/features/chat/application/turns/composition.test.ts b/tests/features/chat/application/turns/composition.test.ts index 794738c2..c236af33 100644 --- a/tests/features/chat/application/turns/composition.test.ts +++ b/tests/features/chat/application/turns/composition.test.ts @@ -113,6 +113,7 @@ describe("createTurnWorkflowActions", () => { composer: { prepareInput, captureInputSnapshot: vi.fn(() => composerSnapshot), + draft: () => "", trimmedDraft: () => "", setDraft: vi.fn(), }, diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index 037499f4..2882f21b 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -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(); + 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(); + 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(); steerTurn.mockImplementation(() => steering.promise); diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index 79e59258..852f9d4e 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -75,6 +75,9 @@ function turnBundleFixture(options: { stateStore?: ReturnType { 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 { diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 89c7afff..1275238b 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -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" }, diff --git a/tests/features/chat/panel/runtime-status-projection.test.ts b/tests/features/chat/panel/runtime-status-projection.test.ts index f1e77d87..d1642c17 100644 --- a/tests/features/chat/panel/runtime-status-projection.test.ts +++ b/tests/features/chat/panel/runtime-status-projection.test.ts @@ -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)" }, ]); }); diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 4197c18c..d60014d9 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -466,6 +466,7 @@ function shellParts( busy: false, canInterrupt: false, submissionDisabled: false, + webSubmissionCancellable: false, normalPlaceholder: "Ask Codex...", suggestions: [], selectedSuggestionIndex: 0, diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 2d24cb3c..cbd46f35 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -124,6 +124,7 @@ function shellParts(store: ReturnType, toolbarPanel busy: false, canInterrupt: false, submissionDisabled: false, + webSubmissionCancellable: false, normalPlaceholder: "Ask Codex...", suggestions: [], selectedSuggestionIndex: 0, diff --git a/tests/features/chat/ui/composer.test.ts b/tests/features/chat/ui/composer.test.ts index ed9f9868..1187c1c0 100644 --- a/tests/features/chat/ui/composer.test.ts +++ b/tests/features/chat/ui/composer.test.ts @@ -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(".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(".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({ diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 3fb48be7..922fec62 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -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" }, ], },