mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(web): make context imports safe and cancellable
This commit is contained in:
parent
2e97525d4f
commit
e7ad2ca4c4
27 changed files with 726 additions and 64 deletions
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ describe("createTurnWorkflowActions", () => {
|
|||
composer: {
|
||||
prepareInput,
|
||||
captureInputSnapshot: vi.fn(() => composerSnapshot),
|
||||
draft: () => "",
|
||||
trimmedDraft: () => "",
|
||||
setDraft: vi.fn(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
|
|||
selectThread: vi.fn(),
|
||||
},
|
||||
composerController: {
|
||||
get draft() {
|
||||
return "/tools";
|
||||
},
|
||||
get trimmedDraft() {
|
||||
return "/tools";
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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)" },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ function shellParts(
|
|||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
webSubmissionCancellable: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue