Show web submissions while fetching context

This commit is contained in:
murashit 2026-07-14 11:01:39 +09:00
parent 2c254339cf
commit 00402e0d56
23 changed files with 874 additions and 84 deletions

View file

@ -19,6 +19,7 @@ interface ResumedThreadActionParams {
items?: readonly ThreadStreamItem[];
preserveRequestedRuntimeSettings?: boolean;
serviceTierKnown?: boolean;
preservePendingSubmissionId?: string;
}
interface ResumedThreadFromActiveRuntimeParams {
@ -55,6 +56,7 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
status?: string;
listedThreads?: readonly Thread[];
preserveRequestedRuntimeSettings?: boolean;
preservePendingSubmissionId?: string;
lifetime?:
| { readonly kind: "persistent" }
| { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null };
@ -113,6 +115,7 @@ export interface TurnOptimisticStartedAction {
type: "turn/optimistic-started";
item: ThreadStreamItem;
pendingTurnStart: PendingTurnStart;
pendingSubmissionId?: string;
}
export interface TurnStartAcknowledgedAction {
@ -170,6 +173,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
? { listedThreads: isSubagentThread(response.thread) ? params.listedThreads : upsertThread(params.listedThreads, response.thread) }
: {}),
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
...(params.preservePendingSubmissionId ? { preservePendingSubmissionId: params.preservePendingSubmissionId } : {}),
};
}

View file

@ -0,0 +1,22 @@
import type { ThreadStreamDialogueItem } from "../../domain/thread-stream/items";
export interface ChatPendingSubmissionState {
readonly id: string;
readonly item: ThreadStreamDialogueItem;
readonly targetThreadId: string | null;
}
export type PendingSubmissionAction =
| { type: "web-submission/pending"; submission: ChatPendingSubmissionState }
| { type: "web-submission/cancelled"; submissionId: string }
| { type: "web-submission/steer-adopted"; submissionId: string; item: ThreadStreamDialogueItem };
export function pendingSubmissionMatches(
state: {
readonly pendingSubmission: ChatPendingSubmissionState | null;
readonly activeThread: { readonly id: string | null };
},
submissionId: string,
): boolean {
return state.pendingSubmission?.id === submissionId && state.pendingSubmission.targetThreadId === state.activeThread.id;
}

View file

@ -28,7 +28,7 @@ import {
resetReasoningEffortToConfigRuntimeState,
setSelectedCollaborationModeRuntimeState,
} from "../../domain/runtime/state";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import type { ThreadStreamDialogueItem, ThreadStreamItem } from "../../domain/thread-stream/items";
import type { ComposerSuggestion } from "../composer/suggestions";
import {
type ChatRequestState,
@ -58,6 +58,7 @@ import type {
TurnStartFailedAction,
} from "./actions";
import { definedPatch, patchObject } from "./patch";
import type { ChatPendingSubmissionState, PendingSubmissionAction } from "./pending-submission";
import {
type ChatThreadStreamState,
initialChatThreadStreamState,
@ -137,6 +138,7 @@ interface ChatStateShape {
runtime: ChatRuntimeState;
turn: ChatTurnState;
threadStream: ChatThreadStreamState;
pendingSubmission: ChatPendingSubmissionState | null;
requests: ChatRequestState;
composer: ChatComposerState;
ui: ChatUiState;
@ -245,7 +247,8 @@ type ChatTransitionAction =
| { type: "panel/view-state-cleared" }
| TurnAction
| RequestResolvedAction
| PendingStartHookUpsertedAction;
| PendingStartHookUpsertedAction
| PendingSubmissionAction;
type ChatSliceAction =
| ConnectionAction
@ -266,6 +269,7 @@ export function createChatState(): ChatState {
runtime: initialChatRuntimeState(),
turn: initialTurnState(),
threadStream: initialThreadStreamState(),
pendingSubmission: null,
requests: initialRequestState(),
composer: initialComposerState(),
ui: initialUiState(),
@ -290,6 +294,9 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
case "turn/start-failed":
case "turn/pending-start-hook-upserted":
case "request/resolved":
case "web-submission/pending":
case "web-submission/cancelled":
case "web-submission/steer-adopted":
return reduceChatTransition(state, action);
default:
return reduceChatSlices(state, action);
@ -330,9 +337,39 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C
return reducePendingStartHookUpsertedTransition(state, action);
case "request/resolved":
return reduceRequestResolvedTransition(state, action);
case "web-submission/pending":
return patchChatState(state, { pendingSubmission: action.submission });
case "web-submission/cancelled":
return state.pendingSubmission?.id === action.submissionId ? patchChatState(state, { pendingSubmission: null }) : state;
case "web-submission/steer-adopted":
if (state.pendingSubmission?.id !== action.submissionId) return state;
return patchChatState(state, {
pendingSubmission: null,
threadStream: adoptPendingSteerItem(state.threadStream, action.item),
});
}
}
function adoptPendingSteerItem(state: ChatThreadStreamState, item: ThreadStreamDialogueItem): ChatThreadStreamState {
if (!item.clientId) {
return reduceThreadStreamSlice(state, { type: "thread-stream/item-added", item });
}
const matchedIndex = state.stableItems.findIndex(
(current) => current.kind === "dialogue" && current.role === "user" && current.clientId === item.clientId,
);
if (matchedIndex === -1) return reduceThreadStreamSlice(state, { type: "thread-stream/item-added", item });
const current = state.stableItems[matchedIndex];
if (current?.kind !== "dialogue") return state;
const stableItems = [...state.stableItems];
stableItems[matchedIndex] = {
...current,
...(item.contextAttachments ? { contextAttachments: item.contextAttachments } : {}),
...(item.mentionedFiles ? { mentionedFiles: item.mentionedFiles } : {}),
...(item.referencedThread ? { referencedThread: item.referencedThread } : {}),
};
return { ...state, stableItems };
}
function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThreadResumedAction): ChatState {
const runtimeBase = action.preserveRequestedRuntimeSettings ? state.runtime : initialChatRuntimeState();
const turnScopedState = clearTurnScopedState(state);
@ -374,6 +411,10 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
},
turn: initialTurnState(),
threadStream: initialThreadStreamState(action.items ?? []),
pendingSubmission:
action.preservePendingSubmissionId && state.pendingSubmission?.id === action.preservePendingSubmissionId
? { ...state.pendingSubmission, targetThreadId: action.thread.id }
: null,
requests: initialRequestState(),
composer: initialComposerState(),
ui: initialUiState(),
@ -462,12 +503,14 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc
}
function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOptimisticStartedAction): ChatState {
if (action.pendingSubmissionId && state.pendingSubmission?.id !== action.pendingSubmissionId) return state;
const lifecycle = transitionChatTurnLifecycleState(state.turn.lifecycle, {
type: "optimistic-started",
pendingTurnStart: action.pendingTurnStart,
});
return patchChatState(state, {
turn: { lifecycle },
pendingSubmission: action.pendingSubmissionId ? null : state.pendingSubmission,
threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]),
});
}
@ -535,6 +578,7 @@ function clearThreadScopedState(state: ChatState): ChatState {
restoration: initialPanelRestorationState(),
runtime: initialChatRuntimeState(),
threadStream: initialThreadStreamState(),
pendingSubmission: null,
composer: initialComposerState(),
ui: initialUiState(),
}),
@ -554,6 +598,7 @@ function clearConnectionScopedState(state: ChatState): ChatState {
availablePermissionProfiles: [],
},
threadList: initialThreadListState(),
pendingSubmission: null,
});
}
@ -565,6 +610,7 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState
restoration: state.restoration,
runtime: reduceRuntimeSlice(state.runtime, action),
turn: state.turn,
pendingSubmission: state.pendingSubmission,
requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests,
threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream,
composer: reduceComposerSlice(state.composer, action),

View file

@ -46,6 +46,7 @@ function cloneChatState(state: ChatState): ChatState {
runtime: { ...state.runtime },
turn: { lifecycle: state.turn.lifecycle },
threadStream: cloneThreadStreamState(state.threadStream),
pendingSubmission: state.pendingSubmission ? { ...state.pendingSubmission, item: { ...state.pendingSubmission.item } } : null,
requests: {
approvals: [...state.requests.approvals],
pendingUserInputs: [...state.requests.pendingUserInputs],

View file

@ -3,6 +3,7 @@ import type { Thread } from "../../../../domain/threads/model";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
import { resumedThreadAction } from "../state/actions";
import { pendingSubmissionMatches } from "../state/pending-submission";
import type { ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import type { ThreadStartTransport } from "./thread-start-transport";
@ -20,7 +21,10 @@ export interface ThreadStartActionsHost {
}
export interface ThreadStartActions {
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<StartedThreadSummary | null>;
startThread: (
preview?: string,
options?: { syncGoal?: boolean; preservePendingSubmissionId?: string },
) => Promise<StartedThreadSummary | null>;
}
export function createThreadStartActions(host: ThreadStartActionsHost): ThreadStartActions {
@ -32,7 +36,7 @@ export function createThreadStartActions(host: ThreadStartActionsHost): ThreadSt
async function startThread(
host: ThreadStartActionsHost,
preview?: string,
options: { syncGoal?: boolean } = {},
options: { syncGoal?: boolean; preservePendingSubmissionId?: string } = {},
): Promise<StartedThreadSummary | null> {
const requestState = host.stateStore.getState();
const runtimeSnapshot = host.runtimeSnapshotForState(requestState);
@ -42,6 +46,9 @@ async function startThread(
permissions: permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig),
});
if (!activation) return null;
if (options.preservePendingSubmissionId && !pendingSubmissionMatches(host.stateStore.getState(), options.preservePendingSubmissionId)) {
return null;
}
const state = host.stateStore.getState();
const fallbackPreview = preview?.trim();
@ -54,6 +61,7 @@ async function startThread(
response: patchedActivation,
listedThreads: state.threadList.listedThreads,
preserveRequestedRuntimeSettings: requestState.activeThread.id === null,
...(options.preservePendingSubmissionId ? { preservePendingSubmissionId: options.preservePendingSubmissionId } : {}),
});
host.stateStore.dispatch(action);
host.recordStartedThread(action.thread);

View file

@ -1,16 +1,20 @@
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 type { ChatStateStore } from "../state/store";
import type { SlashCommandExecutionResult } from "./slash-command-execution";
import { parseWebCommandArgs, type SlashCommandExecutionResult } from "./slash-command-execution";
import { submissionStateSnapshot } from "./submission-state";
import type { TurnSubmissionRequest } from "./turn-submission-actions";
import type { ChatTurnTransport } from "./turn-transport";
import { pendingWebSubmissionItem } from "./web-submission";
const STATUS_INTERRUPT_REQUESTED = "Interrupt requested.";
export interface ComposerSubmitActionsHost {
stateStore: ChatStateStore;
localItemIds: LocalIdSource;
ensureRestoredThreadLoaded?: () => Promise<boolean>;
composer: {
readonly trimmedDraft: string;
@ -48,6 +52,7 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
const draft = host.composer.trimmedDraft;
if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return;
const state = submissionStateSnapshot(host.stateStore.getState());
if (host.stateStore.getState().pendingSubmission) return;
if (state.activeThreadSubagent) {
host.status.addSystemMessage("Messages and slash commands are unavailable in agent threads. Start a new chat to continue.");
return;
@ -65,26 +70,49 @@ async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promi
const slashCommand = parseSlashCommand(text);
if (slashCommand) {
if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.ensureConnected())) return;
const execution = await executeSlashCommandAndRestoreOnFailure(host, slashCommand.command, slashCommand.args, inputSnapshot, text);
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 execution = await executeSlashCommandAndRestoreOnFailure(
host,
slashCommand.command,
slashCommand.args,
inputSnapshot,
text,
pendingWeb?.id,
);
if (execution.failed) return;
const result = execution.result;
if (result?.composerDraft !== undefined) {
host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true });
}
if (result?.sendText) {
host.scroll.showLatest();
if (pendingWeb && !pendingWebSubmissionIsCurrent(host, pendingWeb.id)) return;
if (!pendingWeb) host.scroll.showLatest();
const submitted = await host.turnSubmission.sendTurnText({
text: result.sendText,
inputSnapshot,
...(result.sendInput !== undefined ? { codexInputOverride: result.sendInput } : {}),
...(result.referencedThread !== undefined ? { referencedThread: result.referencedThread } : {}),
...(result.sendInput !== undefined ? { preserveComposerContextOnFailure: true } : {}),
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: text } : {}),
});
if (!submitted) host.composer.setDraft(text, { focus: true, clearSuggestions: true });
if (!submitted) {
if (pendingWeb) {
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, text);
} else {
host.composer.setDraft(text, { focus: true, clearSuggestions: true });
}
}
}
if (result === undefined || (result.sendText === undefined && result.composerDraft === undefined)) {
host.composer.setDraft("", { clearSuggestions: true });
if (pendingWeb) {
if (pendingWebSubmissionIsCurrent(host, pendingWeb.id)) rollbackPendingWebSubmission(host, pendingWeb.id, text);
} else {
host.composer.setDraft("", { clearSuggestions: true });
}
}
return;
}
@ -99,16 +127,53 @@ async function executeSlashCommandAndRestoreOnFailure(
args: string,
inputSnapshot: ComposerInputSnapshot,
originalText: string,
pendingWebSubmissionId?: string,
): Promise<{ failed: false; result: SlashCommandExecutionResult | undefined } | { failed: true }> {
try {
return { failed: false, result: await host.slashCommandExecutor.execute(command, args, inputSnapshot) };
} catch (error) {
if (pendingWebSubmissionId && !pendingWebSubmissionIsCurrent(host, pendingWebSubmissionId)) return { failed: true };
if (pendingWebSubmissionId) cancelPendingWebSubmission(host, pendingWebSubmissionId);
host.composer.setDraft(originalText, { focus: true, clearSuggestions: true });
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
return { failed: true };
}
}
interface PendingWebSubmission {
id: string;
}
function beginPendingWebSubmission(host: ComposerSubmitActionsHost, command: SlashCommandName, args: string): PendingWebSubmission | null {
if (command !== "web") return null;
const parsed = parseWebCommandArgs(args);
if (!parsed) return null;
const id = host.localItemIds.next("local-web");
const item = pendingWebSubmissionItem(id, parsed.url, parsed.message);
if (!item) return null;
const activeThreadId = submissionStateSnapshot(host.stateStore.getState()).activeThreadId;
host.stateStore.dispatch({
type: "web-submission/pending",
submission: { id, item, targetThreadId: activeThreadId },
});
host.composer.setDraft("", { clearSuggestions: true, preserveContext: true });
host.scroll.showLatest();
return { id };
}
function pendingWebSubmissionIsCurrent(host: ComposerSubmitActionsHost, submissionId: string): boolean {
return pendingSubmissionMatches(host.stateStore.getState(), submissionId);
}
function cancelPendingWebSubmission(host: ComposerSubmitActionsHost, id: string): void {
host.stateStore.dispatch({ type: "web-submission/cancelled", submissionId: id });
}
function rollbackPendingWebSubmission(host: ComposerSubmitActionsHost, id: string, text: string): void {
cancelPendingWebSubmission(host, id);
host.composer.setDraft(text, { focus: true, clearSuggestions: true });
}
async function interruptTurn(host: ComposerSubmitActionsHost): Promise<void> {
const state = submissionStateSnapshot(host.stateStore.getState());
const turnId = state.activeTurnId;

View file

@ -62,7 +62,10 @@ export interface TurnWorkflowRefs {
}
interface TurnWorkflowThreadStarter {
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
startThread: (
preview?: string,
options?: { syncGoal?: boolean; preservePendingSubmissionId?: string },
) => Promise<{ threadId: string } | null>;
}
interface PlanImplementation {
@ -93,7 +96,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
localItemIds,
turnTransport,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
startThread: async (preview) => (await refs.threadStarter.startThread(preview)) !== null,
startThread: async (preview, options) => (await refs.threadStarter.startThread(preview, options)) !== null,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
resetThreadTurnPresence: thread.resetTurnPresence,
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
@ -137,6 +140,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
};
const composerSubmitHost: ComposerSubmitActionsHost = {
stateStore,
localItemIds,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
composer: {
get trimmedDraft() {

View file

@ -10,6 +10,8 @@ import type { PendingTurnStart } from "./turn-state";
interface LocalUserDialogueParams {
id: string;
clientId?: string;
interaction?: "prompt" | "steer";
text: string;
copyText?: string;
turnId?: string;
@ -59,7 +61,8 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
role: "user",
text: params.text,
copyText: params.copyText ?? params.text,
provenance: localUserDialogueProvenance(params.id),
provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction),
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles: [...mentionedFiles] } : {}),
@ -67,11 +70,11 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
};
}
function localUserDialogueProvenance(id: string): ThreadStreamItemProvenance {
function localUserDialogueProvenance(id: string, interaction?: "prompt" | "steer"): ThreadStreamItemProvenance {
return {
source: "localUser",
channel: "optimistic",
interaction: isLocalSteerDialogueClientId(id) ? "steer" : "prompt",
interaction: interaction ?? (isLocalSteerDialogueClientId(id) ? "steer" : "prompt"),
sourceId: id,
};
}
@ -79,6 +82,8 @@ function localUserDialogueProvenance(id: string): ThreadStreamItemProvenance {
export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInputParams): ThreadStreamDialogueItem {
return localUserDialogueItem({
id: params.id,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.interaction ? { interaction: params.interaction } : {}),
text: userMessageDisplayText(params.text, params.codexInput),
copyText: params.text,
...(params.turnId ? { turnId: params.turnId } : {}),

View file

@ -146,7 +146,7 @@ export async function executeSlashCommand(
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread };
}
case "web": {
const parsed = parseUrlAndMessageArgs(args);
const parsed = parseWebCommandArgs(args);
if (!parsed) {
context.addSystemMessage(usageError(command, "requires a URL"));
return;
@ -351,7 +351,7 @@ function validateSlashCommandArguments(command: SlashCommandName, args: string):
if (definition.argsKind === "requiredThread" && !args) return usageError(command, "requires a thread");
if (definition.argsKind === "threadAndMessage" && !parseReferArgs(args)) return usageError(command, "requires a thread and a message");
if (definition.argsKind === "threadAndName" && !parseThreadAndNameArgs(args)) return usageError(command, "requires a thread and a name");
if (definition.argsKind === "urlAndOptionalMessage" && !parseUrlAndMessageArgs(args)) return usageError(command, "requires a URL");
if (definition.argsKind === "urlAndOptionalMessage" && !parseWebCommandArgs(args)) return usageError(command, "requires a URL");
return null;
}
@ -506,7 +506,7 @@ function parseThreadAndNameArgs(args: string): { threadQuery: string; text: stri
return text ? { threadQuery: parsed.threadQuery, text } : null;
}
function parseUrlAndMessageArgs(args: string): { url: string; message: string } | null {
export function parseWebCommandArgs(args: string): { url: string; message: string } | null {
const match = /^(\S+)(?:\s+([\s\S]*\S))?\s*$/.exec(args);
if (!match) return null;
const url = match[1];

View file

@ -2,6 +2,7 @@ import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import type { LocalIdSource } from "../local-id-source";
import { pendingSubmissionMatches } from "../state/pending-submission";
import type { ChatStateStore } from "../state/store";
import {
acknowledgeOptimisticTurnStart,
@ -21,7 +22,7 @@ export interface TurnSubmissionActionsHost {
localItemIds: LocalIdSource;
turnTransport: ChatTurnTransport;
ensureRestoredThreadLoaded: () => Promise<boolean>;
startThread: (preview?: string) => Promise<boolean>;
startThread: (preview?: string, options?: { preservePendingSubmissionId?: string }) => Promise<boolean>;
notifyActiveThreadIdentityChanged: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
applyPendingThreadSettings: () => Promise<boolean>;
@ -49,6 +50,8 @@ export interface TurnSubmissionRequest {
codexInputOverride?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
preserveComposerContextOnFailure?: boolean;
pendingSubmissionId?: string;
failureDraft?: string;
}
export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions {
@ -77,33 +80,44 @@ async function sendTurnText(
: inputSnapshot
? host.prepareInput(text, inputSnapshot)
: { text, input: codexTextInput(text) };
if (!pendingRequestIsCurrent(host, request)) return false;
if (!(await host.turnTransport.ensureConnected())) return false;
if (!pendingRequestIsCurrent(host, request)) return false;
if (!(await host.ensureRestoredThreadLoaded())) return false;
if (!pendingRequestIsCurrent(host, request)) return false;
const initialState = submissionStateSnapshot(host.stateStore.getState());
const plan = planTurnSubmission(initialState);
let optimisticUserId: string | null = null;
let optimisticItemId: string | null = null;
let expectedThreadId: string | null = null;
try {
switch (plan.kind) {
case "blocked":
host.addSystemMessage(plan.message);
if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message);
return false;
case "steer":
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request, referencedThread);
case "start-thread-then-turn":
if (!(await startThreadForTurn(host, prepared.text))) return false;
if (!(await startThreadForTurn(host, prepared.text, request.pendingSubmissionId))) 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;
expectedThreadId = activeThreadId;
if (!(await host.applyPendingThreadSettings())) return false;
if (!pendingRequestIsCurrent(host, request) || submissionStateSnapshot(host.stateStore.getState()).activeThreadId !== activeThreadId) {
return false;
}
optimisticUserId = localItemIds.next("local-user");
const clientUserMessageId = localItemIds.next("local-user");
optimisticItemId = request.pendingSubmissionId ?? clientUserMessageId;
const optimistic = optimisticTurnStart({
id: optimisticUserId,
id: optimisticItemId,
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
text: prepared.text,
codexInput: prepared.input,
referencedThread,
@ -112,26 +126,27 @@ async function sendTurnText(
type: "turn/optimistic-started",
item: optimistic.item,
pendingTurnStart: optimistic.pendingTurnStart,
...(request.pendingSubmissionId ? { pendingSubmissionId: request.pendingSubmissionId } : {}),
});
clearDraftForSubmission(host, request);
const response = await host.turnTransport.startTurn({
threadId: activeThreadId,
input: prepared.input,
clientUserMessageId: optimisticUserId,
clientUserMessageId,
});
if (!response) {
const failedState = submissionStateSnapshot(host.stateStore.getState());
if (failedState.activeThreadId !== activeThreadId || failedState.pendingTurnStart?.anchorItemId !== optimisticItemId) return false;
const items = cleanupFailedTurnStart({
items: failedState.items,
optimisticUserId,
optimisticUserId: optimisticItemId,
pendingTurnStart: failedState.pendingTurnStart,
});
host.stateStore.dispatch({ type: "turn/start-failed", items });
restoreSubmittedDraft(host, text, request);
return false;
}
prunePreservedComposerContext(host, request);
const acknowledgedState = submissionStateSnapshot(host.stateStore.getState());
const pendingStart = acknowledgedState.pendingTurnStart;
if (
@ -140,13 +155,14 @@ async function sendTurnText(
activeThreadId: acknowledgedState.activeThreadId,
pendingTurnStart: pendingStart,
activeTurnId: acknowledgedState.activeTurnId,
optimisticUserId,
optimisticUserId: optimisticItemId,
responseTurnId: response.turnId,
})
) {
prunePreservedComposerContext(host, request);
const items = acknowledgeOptimisticTurnStart({
items: acknowledgedState.items,
optimisticUserId,
optimisticUserId: optimisticItemId,
turnId: response.turnId,
pendingTurnStart: pendingStart,
});
@ -156,10 +172,15 @@ async function sendTurnText(
return true;
} catch (error) {
const failedState = submissionStateSnapshot(host.stateStore.getState());
if (!optimisticUserId || failedState.pendingTurnStart?.anchorItemId === optimisticUserId) {
const currentBeforeAdoption = !optimisticItemId && pendingRequestIsCurrent(host, request);
const currentAfterAdoption =
optimisticItemId !== null &&
failedState.activeThreadId === expectedThreadId &&
failedState.pendingTurnStart?.anchorItemId === optimisticItemId;
if (currentBeforeAdoption || currentAfterAdoption) {
const items = cleanupFailedTurnStart({
items: failedState.items,
optimisticUserId,
optimisticUserId: optimisticItemId,
pendingTurnStart: failedState.pendingTurnStart,
});
host.stateStore.dispatch({ type: "turn/start-failed", items });
@ -182,8 +203,11 @@ function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan {
return state.activeThreadId ? { kind: "start-turn", threadId: state.activeThreadId } : { kind: "start-thread-then-turn" };
}
async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string): Promise<boolean> {
if (!(await host.startThread(text))) return false;
async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string, pendingSubmissionId?: string): Promise<boolean> {
const started = pendingSubmissionId
? await host.startThread(text, { preservePendingSubmissionId: pendingSubmissionId })
: await host.startThread(text);
if (!started) return false;
host.notifyActiveThreadIdentityChanged();
host.resetThreadTurnPresence(false);
return true;
@ -198,6 +222,7 @@ async function steerCurrentTurn(
request: TurnSubmissionRequest,
referencedThread?: ReferencedThreadMetadata,
): Promise<boolean> {
if (!pendingRequestIsCurrent(host, request)) return false;
const localSteerId = localItemIds.next("local-steer");
clearDraftForSubmission(host, request, { clearSuggestions: true });
@ -209,25 +234,32 @@ async function steerCurrentTurn(
clientUserMessageId: localSteerId,
});
if (!steered) {
restoreSubmittedDraft(host, text, request, { focus: true });
if (pendingRequestIsCurrent(host, request)) restoreSubmittedDraft(host, text, request, { focus: true });
return false;
}
const currentTurn = isCurrentTurn(host, plan.threadId, plan.turnId);
if (!pendingRequestIsCurrent(host, request) || (!currentTurn && !request.pendingSubmissionId)) return true;
prunePreservedComposerContext(host, request, { clearSuggestions: true });
if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return true;
host.stateStore.dispatch({
type: "thread-stream/item-added",
item: localUserDialogueItemFromInput({
id: localSteerId,
text: prepared.text,
turnId: plan.turnId,
referencedThread,
codexInput: prepared.input,
}),
const item = localUserDialogueItemFromInput({
id: request.pendingSubmissionId ?? localSteerId,
...(request.pendingSubmissionId ? { clientId: localSteerId, interaction: "steer" as const } : {}),
text: prepared.text,
turnId: plan.turnId,
referencedThread,
codexInput: prepared.input,
});
host.setStatus(STATUS_STEERED_CURRENT_TURN);
host.stateStore.dispatch(
request.pendingSubmissionId
? { type: "web-submission/steer-adopted", submissionId: request.pendingSubmissionId, item }
: { type: "thread-stream/item-added", item },
);
if (currentTurn) host.setStatus(STATUS_STEERED_CURRENT_TURN);
return true;
} catch (error) {
if (isCurrentTurn(host, plan.threadId, plan.turnId)) {
if (
isCurrentTurn(host, plan.threadId, plan.turnId) ||
(request.pendingSubmissionId !== undefined && pendingRequestIsCurrent(host, request))
) {
restoreSubmittedDraft(host, text, request, { focus: true });
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -257,7 +289,7 @@ function restoreSubmittedDraft(
request: TurnSubmissionRequest,
options: { focus?: boolean } = {},
): void {
host.setDraft(text, {
host.setDraft(request.failureDraft ?? text, {
...options,
...(request.preserveComposerContextOnFailure ? { preserveContext: true } : {}),
});
@ -275,3 +307,7 @@ function isCurrentTurn(host: TurnSubmissionActionsHost, threadId: string, turnId
const state = submissionStateSnapshot(host.stateStore.getState());
return state.activeThreadId === threadId && state.activeTurnId === turnId;
}
function pendingRequestIsCurrent(host: TurnSubmissionActionsHost, request: TurnSubmissionRequest): boolean {
return request.pendingSubmissionId ? pendingSubmissionMatches(host.stateStore.getState(), request.pendingSubmissionId) : true;
}

View file

@ -0,0 +1,28 @@
import type { ThreadStreamDialogueItem } from "../../domain/thread-stream/items";
export function pendingWebSubmissionItem(id: string, url: string, message: string): ThreadStreamDialogueItem | null {
const normalizedUrl = normalizedHttpUrl(url);
if (!normalizedUrl) return null;
const text = [normalizedUrl, message.trim()].filter(Boolean).join(" ");
return {
id,
kind: "dialogue",
dialogueKind: "user",
role: "user",
text,
copyText: text,
executionState: "running",
contextAttachments: [{ label: "Web page", detail: normalizedUrl }],
provenance: { source: "localUser", channel: "preflight", interaction: "prompt", sourceId: id },
};
}
export function normalizedHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString();
} catch {
return null;
}
}

View file

@ -14,8 +14,8 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn
const contextAttachmentsByClientId = new Map(
currentItems.flatMap((item) =>
isUserDialogue(item) && isLocalUserDialogueId(item.id) && item.contextAttachments
? [[item.id, item.contextAttachments] as const]
isOptimisticUserDialogue(item) && item.contextAttachments
? [[optimisticDialogueClientId(item), item.contextAttachments] as const]
: [],
),
);
@ -65,7 +65,7 @@ function fallbackContextAttachmentsByText(
): Map<string, ThreadStreamDialogueItem["contextAttachments"]> {
const attachmentsByText = new Map<string, ThreadStreamDialogueItem["contextAttachments"]>();
for (const item of currentItems) {
if (!isUserDialogue(item) || !isLocalUserDialogueId(item.id) || !item.contextAttachments) continue;
if (!isOptimisticUserDialogue(item) || !item.contextAttachments) continue;
if (item.turnId && item.turnId !== completedTurnId) continue;
attachmentsByText.set(item.copyText ?? item.text, item.contextAttachments);
}
@ -80,8 +80,8 @@ function serverUserDialogueForOptimisticItem(
item: ThreadStreamItem,
serverUserDialoguesByClientId: ReadonlyMap<string, ThreadStreamDialogueItem & { role: "user" }>,
): (ThreadStreamDialogueItem & { role: "user" }) | null {
if (!isUserDialogue(item) || !isLocalUserDialogueId(item.id)) return null;
return serverUserDialoguesByClientId.get(item.id) ?? null;
if (!isOptimisticUserDialogue(item)) return null;
return serverUserDialoguesByClientId.get(optimisticDialogueClientId(item)) ?? null;
}
function isReconciledOptimisticUserDialogue(
@ -90,13 +90,24 @@ function isReconciledOptimisticUserDialogue(
serverUserDialogueClientIds: Set<string>,
serverUserDialogueFallbackTexts: Set<string>,
): boolean {
if (!isUserDialogue(item) || !isLocalUserDialogueId(item.id)) return false;
if (!isOptimisticUserDialogue(item)) return false;
return (
serverUserDialogueClientIds.has(item.id) ||
serverUserDialogueClientIds.has(optimisticDialogueClientId(item)) ||
isFallbackOptimisticUserDialogueForTurn(item, completedTurnId, serverUserDialogueFallbackTexts)
);
}
function isOptimisticUserDialogue(item: ThreadStreamItem): item is ThreadStreamDialogueItem & { role: "user" } {
return (
isUserDialogue(item) &&
(isLocalUserDialogueId(item.id) || (item.provenance?.source === "localUser" && item.provenance.channel === "optimistic"))
);
}
function optimisticDialogueClientId(item: ThreadStreamDialogueItem): string {
return item.clientId ?? item.id;
}
function isFallbackOptimisticUserDialogueForTurn(
item: ThreadStreamDialogueItem & { role: "user" },
completedTurnId: string,

View file

@ -13,7 +13,7 @@ export type ThreadStreamItemProvenance =
}
| {
source: "localUser";
channel: "optimistic" | "response";
channel: "preflight" | "optimistic" | "response";
interaction: "prompt" | "steer" | "approvalResponse" | "userInputResponse";
sourceId?: string;
}

View file

@ -1,9 +1,10 @@
import Defuddle from "defuddle";
import { htmlToMarkdown, requestUrl } from "obsidian";
import { htmlToMarkdown, type RequestUrlResponse, requestUrl } from "obsidian";
import type { CodexInput } from "../../../../domain/chat/input";
import { codexTextInputWithAttachments } from "../../../../domain/chat/input";
import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot";
import { normalizedHttpUrl } from "../../application/turns/web-submission";
import { WEB_CONTEXT_KEY } from "../../domain/thread-stream/format/context-attachments";
export interface WebUrlInput {
@ -14,6 +15,7 @@ export interface WebUrlInput {
interface WebContextReaderOptions {
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
viewWindow: () => Window | null;
requestTimeoutMs?: number;
}
type DomParserWindow = Window & { DOMParser: typeof DOMParser };
@ -51,7 +53,7 @@ async function readUrlToInput(
}
async function fetchWebPage(options: WebContextReaderOptions, url: string): Promise<{ title: string; content: string }> {
const response = await requestUrl({ url, method: "GET", throw: false });
const response = await requestWebPage(options, url, options.requestTimeoutMs ?? 30_000);
if (response.status < 200 || response.status >= 300) {
throw new Error(`Web request failed for ${url} (HTTP ${String(response.status)}).`);
}
@ -62,6 +64,26 @@ async function fetchWebPage(options: WebContextReaderOptions, url: string): Prom
return { title: result.title, content: result.content };
}
function requestWebPage(options: WebContextReaderOptions, url: string, timeoutMs: number): Promise<RequestUrlResponse> {
const timerHost = options.viewWindow();
if (!timerHost) throw new Error(`Web request unavailable for ${url}.`);
return new Promise((resolve, reject) => {
const timeout = timerHost.setTimeout(() => {
reject(new Error(`Web request timed out for ${url}.`));
}, timeoutMs);
void requestUrl({ url, method: "GET", throw: false }).then(
(response) => {
timerHost.clearTimeout(timeout);
resolve(response);
},
(error: unknown) => {
timerHost.clearTimeout(timeout);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
function webContextValue(url: string, title: string, content: string): string {
return [
"Web page context for the current user input:",
@ -71,13 +93,3 @@ function webContextValue(url: string, title: string, content: string): string {
content,
].join("\n");
}
function normalizedHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString();
} catch {
return null;
}
}

View file

@ -103,7 +103,7 @@ export class ChatComposerController {
draft: model.draft.value,
busy: model.turnBusy.value,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.activeThreadSubagent.value,
submissionDisabled: model.activeThreadSubagent.value || model.webSubmissionPending.value,
normalPlaceholder: projection.placeholder,
suggestions: model.suggestions.value,
selectedSuggestionIndex: model.selectedSuggestionIndex.value,

View file

@ -34,6 +34,7 @@ interface ChatPanelShellSignals {
runtime: Signal<ChatState["runtime"]>;
turn: Signal<ChatState["turn"]>;
threadStream: Signal<ChatState["threadStream"]>;
pendingSubmission: Signal<ChatState["pendingSubmission"]>;
requests: Signal<ChatState["requests"]>;
composer: Signal<ChatState["composer"]>;
ui: Signal<ChatState["ui"]>;
@ -48,6 +49,7 @@ interface ChatPanelShellSignals {
threadStreamRollbackCandidate: ReadonlySignal<ThreadStreamRollbackCandidate | null>;
threadStreamForkCandidates: ReadonlySignal<readonly ForkCandidate[]>;
threadStreamImplementPlanTarget: ReadonlySignal<PlanImplementationTarget | null>;
webSubmissionPending: ReadonlySignal<boolean>;
threadStreamDisclosures: ReadonlySignal<ChatPanelThreadStreamDisclosureState>;
threadStreamForkMenuItemId: ReadonlySignal<ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"]>;
hasThreadTurns: ReadonlySignal<boolean>;
@ -140,6 +142,7 @@ export interface ChatPanelComposerReadModel {
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
readonly activeThreadId: ReadonlySignal<ChatState["activeThread"]["id"]>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly webSubmissionPending: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly activeTurnId: ReadonlySignal<string | null>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
@ -152,12 +155,14 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
const runtime = signal(initialState.runtime);
const turn = signal(initialState.turn);
const threadStream = signal(initialState.threadStream);
const pendingSubmission = signal(initialState.pendingSubmission);
const requests = signal(initialState.requests);
const composer = signal(initialState.composer);
const ui = signal(initialState.ui);
const turnBusy = computed(() => chatTurnBusy({ turn: turn.value }));
const streamItems = computed(() => threadStreamItems(threadStream.value));
const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(streamItems.value));
const canonicalStreamItems = computed(() => threadStreamItems(threadStream.value));
const streamItems = computed(() => appendPendingSubmission(canonicalStreamItems.value, pendingSubmission.value));
const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(canonicalStreamItems.value));
const activeThreadIdSignal = computed(() => activeThread.value.id);
const activeThreadCwd = computed(() => activeThread.value.cwd);
const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage);
@ -169,6 +174,7 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
runtime,
turn,
threadStream,
pendingSubmission,
requests,
composer,
ui,
@ -178,17 +184,25 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
activeThreadCwd,
activeThreadGoal,
threadStreamItems: streamItems,
threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)),
threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)),
threadStreamStableItems: computed(() =>
threadStream.value.activeSegment
? threadStreamStableItems(threadStream.value)
: appendPendingSubmission(threadStreamStableItems(threadStream.value), pendingSubmission.value),
),
threadStreamActiveItems: computed(() =>
threadStream.value.activeSegment
? appendPendingSubmission(threadStreamActiveItems(threadStream.value), pendingSubmission.value)
: threadStreamActiveItems(threadStream.value),
),
threadStreamRollbackCandidate: computed(() =>
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
? null
: threadStreamRollbackCandidateFromItems(streamItems.value),
: threadStreamRollbackCandidateFromItems(canonicalStreamItems.value),
),
threadStreamForkCandidates: computed(() =>
turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent"
? []
: forkCandidatesFromItems(streamItems.value),
: forkCandidatesFromItems(canonicalStreamItems.value),
),
threadStreamImplementPlanTarget: computed(() =>
implementPlanTargetFromState({
@ -198,6 +212,7 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C
threadStream: threadStream.value,
}),
),
webSubmissionPending: computed(() => pendingSubmission.value !== null),
threadStreamDisclosures: createThreadStreamDisclosuresSignal(ui),
threadStreamForkMenuItemId: computed(() => ui.value.threadStreamActionMenu.forkMenuItemId),
hasThreadTurns,
@ -241,12 +256,20 @@ function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState):
if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime;
if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn;
if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream;
if (signals.pendingSubmission.value !== nextState.pendingSubmission) signals.pendingSubmission.value = nextState.pendingSubmission;
if (signals.requests.value !== nextState.requests) signals.requests.value = nextState.requests;
if (signals.composer.value !== nextState.composer) signals.composer.value = nextState.composer;
if (signals.ui.value !== nextState.ui) signals.ui.value = nextState.ui;
});
}
function appendPendingSubmission(
items: readonly ThreadStreamItem[],
pendingSubmission: ChatState["pendingSubmission"],
): readonly ThreadStreamItem[] {
return pendingSubmission ? [...items, pendingSubmission.item] : items;
}
function shellReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelShellReadModel {
return {
toolbar: toolbarReadModelFromSignals(signals),
@ -340,6 +363,7 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel
selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected),
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"),
webSubmissionPending: signals.webSubmissionPending,
turnBusy: signals.turnBusy,
activeTurnId: signals.activeTurnId,
runtimeSnapshot: signals.composerRuntimeSnapshot,

View file

@ -4,9 +4,12 @@ import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/co
import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation";
import type { Thread } from "../../../../../src/domain/threads/model";
import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
import { resumedThreadAction } from "../../../../../src/features/chat/application/state/actions";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { createThreadStartActions } from "../../../../../src/features/chat/application/threads/thread-start-actions";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import { deferred } from "../../../../support/async";
import { chatStateFixture, chatStateWith } from "../../support/state";
describe("thread start actions", () => {
@ -89,6 +92,54 @@ describe("thread start actions", () => {
expect(syncThreadGoal).not.toHaveBeenCalled();
});
it("retargets an explicitly preserved pending submission to the newly started thread", async () => {
const stateStore = createChatStateStore(chatStateFixture());
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 },
});
const actions = createThreadStartActions({
stateStore,
threadStartTransport: { startThread: vi.fn().mockResolvedValue(activationFixture(threadFixture("started"))) },
runtimeSnapshotForState: runtimeSnapshotForChatState,
recordStartedThread: vi.fn(),
syncThreadGoal: vi.fn(),
});
await actions.startThread(pending.text, { preservePendingSubmissionId: pending.id });
expect(stateStore.getState().pendingSubmission).toMatchObject({ id: pending.id, targetThreadId: "started" });
});
it("does not activate a delayed new thread after its pending submission is superseded", async () => {
const stateStore = createChatStateStore(chatStateFixture());
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 },
});
const started = deferred<ThreadActivationSnapshot | null>();
const recordStartedThread = vi.fn();
const actions = createThreadStartActions({
stateStore,
threadStartTransport: { startThread: vi.fn(() => started.promise) },
runtimeSnapshotForState: runtimeSnapshotForChatState,
recordStartedThread,
syncThreadGoal: vi.fn(),
});
const starting = actions.startThread(pending.text, { preservePendingSubmissionId: pending.id });
stateStore.dispatch(resumedThreadAction({ response: activationFixture(threadFixture("selected")) }));
started.resolve(activationFixture(threadFixture("delayed")));
await expect(starting).resolves.toBeNull();
expect(stateStore.getState().activeThread.id).toBe("selected");
expect(recordStartedThread).not.toHaveBeenCalled();
});
it("starts threads with service tier from explicit effective config", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "flex" } } });

View file

@ -1,9 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import type { Thread } from "../../../../../src/domain/threads/model";
import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { submitComposer } from "../../../../../src/features/chat/application/turns/composer-submit-actions";
import { deferred } from "../../../../support/async";
import { chatStateThreadStreamItems } from "../../support/thread-stream";
function thread(id: string): Thread {
return {
@ -49,6 +52,7 @@ function createHost(draft: string, options: { subagent?: boolean } = {}) {
const captureInputSnapshot = vi.fn(() => inputSnapshot);
const host = {
stateStore,
localItemIds: createLocalIdSource(),
composer: {
get trimmedDraft() {
return draft;
@ -170,10 +174,111 @@ describe("submitComposer", () => {
{ type: "mention", name: "Note", path: "Note.md" },
],
preserveComposerContextOnFailure: true,
pendingSubmissionId: expect.stringMatching(/^local-web-/),
failureDraft: "/web https://example.com [[Note]]",
});
expect(setDraft).toHaveBeenCalledWith("/web https://example.com [[Note]]", { focus: true, clearSuggestions: true });
});
it("shows a pending web message while context is fetched and hands it to turn submission", async () => {
const { host, execute, inputSnapshot, sendTurnText, setDraft, showLatest, stateStore } = createHost(
"/web https://example.com summarize",
);
const fetch = deferred<{ sendText: string; sendInput: [{ type: "text"; text: string }] }>();
execute.mockImplementation(() => fetch.promise);
const submitting = submitComposer(host);
await vi.waitFor(() => {
expect(stateStore.getState().pendingSubmission).not.toBeNull();
});
const pending = stateStore.getState().pendingSubmission?.item;
expect(pending).toMatchObject({
kind: "dialogue",
text: "https://example.com/ summarize",
contextAttachments: [{ label: "Web page", detail: "https://example.com/" }],
provenance: { source: "localUser", channel: "preflight" },
});
expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true, preserveContext: true });
expect(showLatest).toHaveBeenCalledOnce();
expect(sendTurnText).not.toHaveBeenCalled();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
fetch.resolve({ sendText: "https://example.com/ summarize", sendInput: [{ type: "text", text: "prepared" }] });
await submitting;
expect(sendTurnText).toHaveBeenCalledWith({
text: "https://example.com/ summarize",
inputSnapshot,
codexInputOverride: [{ type: "text", text: "prepared" }],
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending?.id,
failureDraft: "/web https://example.com summarize",
});
});
it("prevents a second submission while web context is being fetched", async () => {
const { host, execute } = 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 submitComposer(host);
expect(execute).toHaveBeenCalledOnce();
fetch.resolve({ sendText: "https://example.com/ summarize" });
await first;
});
it("drops a pending web submission when the active thread changes during fetch", async () => {
const { host, execute, sendTurnText, stateStore } = createHost("/web https://example.com summarize");
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("first"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
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: "active-thread/cleared" });
fetch.resolve({ sendText: "https://example.com/ summarize" });
await submitting;
expect(sendTurnText).not.toHaveBeenCalled();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().pendingSubmission).toBeNull();
});
it("does not restore or report a stale web fetch failure after the active thread changes", async () => {
const { host, execute, setDraft, stateStore } = createHost("/web https://example.com summarize");
resumeActiveThread(stateStore, "first");
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: "active-thread/cleared" });
fetch.reject(new Error("offline"));
await submitting;
expect(setDraft.mock.calls).toEqual([["", { clearSuggestions: true, preserveContext: true }]]);
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
expect(stateStore.getState().pendingSubmission).toBeNull();
});
it("does not execute connection-dependent slash commands when connection fails", async () => {
const { host, ensureConnected, execute, setDraft } = createHost("/clear");
ensureConnected.mockResolvedValue(false);
@ -185,6 +290,21 @@ describe("submitComposer", () => {
expect(execute).not.toHaveBeenCalled();
});
it("rolls back a pending web message when connection setup fails", async () => {
const { host, ensureConnected, execute, setDraft, stateStore } = createHost("/web https://example.com summarize");
ensureConnected.mockResolvedValue(false);
await submitComposer(host);
expect(execute).not.toHaveBeenCalled();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(setDraft.mock.calls).toEqual([
["", { clearSuggestions: true, preserveContext: true }],
["/web https://example.com summarize", { focus: true, clearSuggestions: true }],
]);
});
it("executes reconnect without a connected client preflight", async () => {
const { host, ensureConnected, execute, setDraft } = createHost("/reconnect");
@ -233,8 +353,9 @@ describe("submitComposer", () => {
{ focus: true, clearSuggestions: true },
]);
expect(host.status.addSystemMessage).toHaveBeenCalledWith("No readable content found for https://obsidian.md/help/plugins/web-viewer");
expect(showLatest).not.toHaveBeenCalled();
expect(showLatest).toHaveBeenCalledOnce();
expect(sendTurnText).not.toHaveBeenCalled();
expect(chatStateThreadStreamItems(host.stateStore.getState())).toEqual([]);
});
it("interrupts a running turn when submitting an empty draft", async () => {
@ -262,3 +383,21 @@ describe("submitComposer", () => {
expect(interruptTurn).toHaveBeenCalledWith("thread", "turn");
});
});
function resumeActiveThread(stateStore: ReturnType<typeof createChatStateStore>, id: string): void {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread(id),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
}

View file

@ -11,6 +11,7 @@ import {
createTurnSubmissionActions,
type TurnSubmissionActionsHost,
} from "../../../../../src/features/chat/application/turns/turn-submission-actions";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { deferred } from "../../../../support/async";
import { chatStateThreadStreamItems } from "../../support/thread-stream";
@ -43,8 +44,8 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
interruptTurn: vi.fn().mockResolvedValue(true),
},
ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true),
startThread: vi.fn().mockImplementation(async () => {
resumeThread(stateStore);
startThread: vi.fn().mockImplementation(async (_preview, options) => {
resumeThread(stateStore, options?.preservePendingSubmissionId);
return true;
}),
notifyActiveThreadIdentityChanged: vi.fn(),
@ -60,7 +61,7 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
return { host, startTurn, stateStore, steerTurn };
}
function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
function resumeThread(stateStore: ReturnType<typeof createChatStateStore>, preservePendingSubmissionId?: string, threadId = "thread") {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -69,12 +70,13 @@ function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
thread: thread(threadId),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
...(preservePendingSubmissionId ? { preservePendingSubmissionId } : {}),
});
}
@ -166,6 +168,146 @@ describe("TurnSubmissionActions", () => {
expect(host.setStatus).toHaveBeenCalledWith("Turn running...");
});
it("replaces a pending web submission when starting a turn", async () => {
const { host, stateStore } = createHost();
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 },
});
const actions = createTurnSubmissionActions(host);
await actions.sendTurnText({
text: "https://example.com/ summarize",
codexInputOverride: textInput("https://example.com/ summarize"),
pendingSubmissionId: pending.id,
});
const dialogues = chatStateThreadStreamItems(stateStore.getState()).filter((item) => item.kind === "dialogue");
expect(dialogues).toHaveLength(1);
expect(dialogues[0]).toMatchObject({
id: pending.id,
clientId: expect.stringMatching(/^local-user-/),
text: "https://example.com/ summarize",
});
});
it("keeps a pending web submission visible through internal thread creation and settings", async () => {
const settings = deferred<boolean>();
const { host, startTurn, stateStore } = createHost({ applyPendingThreadSettings: vi.fn(() => settings.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: null },
});
const actions = createTurnSubmissionActions(host);
const submitting = actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
pendingSubmissionId: pending.id,
});
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(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
settings.resolve(true);
await expect(submitting).resolves.toBe(true);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(chatStateThreadStreamItems(stateStore.getState())[0]).toMatchObject({
id: pending.id,
clientId: startTurn.mock.calls[0]?.[0].clientUserMessageId,
});
});
it("restores the original web command when starting the adopted turn returns no response", async () => {
const { host, startTurn, stateStore } = createHost();
resumeThread(stateStore);
startTurn.mockResolvedValue(null);
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" },
});
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(host.setDraft).toHaveBeenCalledWith("/web https://example.com summarize", { preserveContext: true });
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
});
it("restores the original web command when starting the adopted turn throws", async () => {
const { host, startTurn, stateStore } = createHost();
resumeThread(stateStore);
startTurn.mockRejectedValue(new Error("offline"));
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" },
});
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(host.setDraft).toHaveBeenCalledWith("/web https://example.com summarize", { preserveContext: true });
expect(host.addSystemMessage).toHaveBeenCalledWith("offline");
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]);
});
it("does not send fetched web context to a thread selected while settings are pending", async () => {
const settings = deferred<boolean>();
const { host, startTurn, stateStore } = createHost({ applyPendingThreadSettings: vi.fn(() => settings.promise) });
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" },
});
const actions = createTurnSubmissionActions(host);
const submitting = actions.sendTurnText({
text: pending.text,
codexInputOverride: textInput(pending.text),
pendingSubmissionId: pending.id,
});
await vi.waitFor(() => expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce());
stateStore.dispatch({ type: "active-thread/cleared" });
resumeThread(stateStore, undefined, "other-thread");
settings.resolve(true);
await expect(submitting).resolves.toBe(false);
expect(startTurn).not.toHaveBeenCalled();
expect(host.setDraft).not.toHaveBeenCalledWith(pending.text, expect.anything());
expect(host.addSystemMessage).not.toHaveBeenCalled();
});
it("applies reserved runtime settings after creating a thread and before starting the turn", async () => {
const { host, startTurn, stateStore } = createHost();
const applyPendingThreadSettings = vi.fn().mockImplementation(async () => {
@ -295,6 +437,96 @@ describe("TurnSubmissionActions", () => {
).toBe(true);
});
it("replaces a pending web submission after steering succeeds", async () => {
const { host, stateStore } = createHost();
resumeThread(stateStore);
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
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" },
});
const actions = createTurnSubmissionActions(host);
await actions.sendTurnText({
text: "https://example.com/ summarize",
codexInputOverride: textInput("https://example.com/ summarize"),
pendingSubmissionId: pending.id,
});
const dialogues = chatStateThreadStreamItems(stateStore.getState()).filter((item) => item.kind === "dialogue");
expect(dialogues).toHaveLength(1);
expect(dialogues[0]).toMatchObject({
id: pending.id,
clientId: expect.stringMatching(/^local-steer-/),
text: "https://example.com/ summarize",
turnId: "turn",
});
});
it("adopts a successful pending web steer after the active turn completes", async () => {
const { host, stateStore, steerTurn } = createHost();
resumeThread(stateStore);
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
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" },
});
const steering = deferred<boolean>();
steerTurn.mockImplementation(() => steering.promise);
const actions = createTurnSubmissionActions(host);
const input = [
{ type: "text" as const, text: pending.text },
{
type: "additionalContext" as const,
key: "codex_panel_web_context",
kind: "untrusted" as const,
value: "Web page context for the current user input:\nSource: https://example.com/\n\nReadable article",
},
];
const submitting = actions.sendTurnText({
text: pending.text,
codexInputOverride: input,
preserveComposerContextOnFailure: true,
pendingSubmissionId: pending.id,
failureDraft: "/web https://example.com summarize",
});
await vi.waitFor(() => expect(steerTurn).toHaveBeenCalledOnce());
const clientId = steerTurn.mock.calls[0]?.[0].clientUserMessageId;
stateStore.dispatch({
type: "turn/completed",
turnId: "turn",
status: "completed",
items: [
{
id: "server-user",
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: pending.text,
copyText: pending.text,
turnId: "turn",
clientId,
},
],
});
steering.resolve(true);
await expect(submitting).resolves.toBe(true);
expect(stateStore.getState().pendingSubmission).toBeNull();
expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([
expect.objectContaining({
id: "server-user",
clientId,
contextAttachments: [{ label: "Web page", detail: "https://example.com/" }],
}),
]);
});
it("reports busy turns that cannot be steered", async () => {
const { host, startTurn, stateStore, steerTurn } = createHost();
resumeThread(stateStore);

View file

@ -38,6 +38,24 @@ describe("reconcileCompletedTurnItems", () => {
]);
});
it("reconciles a stable pending display id through its reserved client id", () => {
const optimistic = {
...userDialogue("local-web-1", "https://example.com/ summarize", "turn", "local-user-1"),
contextAttachments: [{ label: "Web page", detail: "https://example.com/" }],
provenance: { source: "localUser", channel: "optimistic", interaction: "prompt", sourceId: "local-user-1" },
} satisfies ThreadStreamItem;
const server = userDialogue("server-user", optimistic.text, "turn", "local-user-1");
const next = reconcileCompletedTurnItems({ currentItems: [optimistic], completedTurnId: "turn", turnItems: [server] });
expect(next).toEqual([
expect.objectContaining({
id: "server-user",
contextAttachments: [{ label: "Web page", detail: "https://example.com/" }],
}),
]);
});
it("keeps local context attachment metadata when reconciling by text without client ids", () => {
const optimistic = {
...userDialogue("local-user-1", "https://example.com/ summarize this", "turn"),

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexInput } from "../../../../src/domain/chat/input";
import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot";
import { deferred } from "../../../support/async";
const mocks = vi.hoisted(() => ({
defuddleParse: vi.fn(),
@ -52,7 +53,7 @@ describe("web context reader", () => {
const result = await createWebContextReader({
prepareInput,
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
viewWindow: fakeDomWindow,
}).readUrl("https://example.com/article", " Summarize [[Alpha]] [[Files/Sketch.png]] ", inputSnapshot);
expect(mocks.requestUrl).toHaveBeenCalledWith({ url: "https://example.com/article", method: "GET", throw: false });
@ -82,7 +83,7 @@ describe("web context reader", () => {
await expect(
createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
viewWindow: fakeDomWindow,
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot),
).rejects.toThrow(`Web request failed for https://example.com/article (HTTP ${status}).`);
@ -95,7 +96,7 @@ describe("web context reader", () => {
await expect(
createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
viewWindow: fakeDomWindow,
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot),
).rejects.toThrow("No readable web content found for https://example.com/article");
});
@ -104,12 +105,32 @@ describe("web context reader", () => {
await expect(
createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
viewWindow: fakeDomWindow,
}).readUrl("file:///tmp/article.html", "", {} as ComposerInputSnapshot),
).rejects.toThrow("Unsupported web URL: file:///tmp/article.html");
expect(mocks.requestUrl).not.toHaveBeenCalled();
});
it("rejects web requests that do not respond before the timeout", async () => {
vi.useFakeTimers();
try {
const response = deferred<never>();
mocks.requestUrl.mockReturnValue(response.promise);
const reading = createWebContextReader({
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
viewWindow: fakeDomWindow,
requestTimeoutMs: 10,
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot);
const rejected = expect(reading).rejects.toThrow("Web request timed out for https://example.com/article.");
await vi.advanceTimersByTimeAsync(10);
await rejected;
} finally {
vi.useRealTimers();
}
});
});
class FakeDOMParser {
@ -117,3 +138,11 @@ class FakeDOMParser {
return {} as Document;
}
}
function fakeDomWindow(): Window {
return {
DOMParser: FakeDOMParser,
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
} as unknown as Window;
}

View file

@ -11,13 +11,15 @@ import type {
import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context";
import type { ChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { pendingWebSubmissionItem } from "../../../../src/features/chat/application/turns/web-submission";
import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller";
import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model";
import { ComposerShell } from "../../../../src/features/chat/ui/composer";
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom";
import { deferred } from "../../../support/async";
import { installObsidianDomShims } from "../../../support/dom";
import { composerReadModelFromChatState } from "../support/shell-read-model";
import { composerReadModelFromChatState, threadStreamReadModelFromChatState } from "../support/shell-read-model";
installObsidianDomShims();
@ -82,6 +84,46 @@ function composerControllerFixture(
}
describe("ChatComposerController", () => {
it("disables composer submission while web context is being fetched", () => {
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 },
});
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.submissionDisabled).toBe(true);
});
it("keeps a pending web submission after a turn that completes during the fetch", () => {
const stateStore = createChatStateStore();
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const assistant: ThreadStreamItem = {
id: "assistant",
kind: "dialogue",
dialogueKind: "assistantResponse",
role: "assistant",
text: "done",
dialogueState: "completed",
turnId: "turn",
};
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
stateStore.dispatch({
type: "web-submission/pending",
submission: { id: pending.id, item: pending, targetThreadId: "thread" },
});
stateStore.dispatch({ type: "turn/completed", turnId: "turn", status: "completed", items: [assistant] });
const model = threadStreamReadModelFromChatState(stateStore.getState());
expect(model.items.value.map((item) => item.id)).toEqual(["assistant", pending.id]);
expect(model.stableItems.value.map((item) => item.id)).toEqual(["assistant", pending.id]);
expect(stateStore.getState().pendingSubmission?.id).toBe(pending.id);
});
it("derives composer placeholder and meta from the projection", () => {
const projection = vi.fn((model: ChatPanelComposerReadModel) => ({
placeholder: `Projected ${model.draft.value || "empty"}`,

View file

@ -4,6 +4,7 @@ import { MarkdownRenderer } from "obsidian";
import { act } from "preact/test-utils";
import { describe, expect, it, vi } from "vitest";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/turns/plan-implementation";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
import { THREAD_STREAM_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom";
@ -1241,6 +1242,18 @@ describe("thread stream rendering and action menu", () => {
expect(user.querySelector(".codex-panel__context-items")?.textContent).toContain("https://example.com/");
});
it("renders pending web submissions immediately as running user dialogue", () => {
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const blocks = threadStreamBlocks({ items: [pending] });
const user = renderThreadStreamBlockElement(expectPresent(blocks.find((block) => block.key === "item:local-web")));
expect(user.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("https://example.com/ summarize");
expect(user.querySelector(".codex-panel__context-items summary")?.textContent).toBe("Context · 1 item");
expect(user.classList.contains("codex-panel__execution--running")).toBe(true);
});
it("does not render the open diff action without aggregated turn diff", () => {
const blocks = threadStreamBlocks({
items: [