From 00402e0d5687f6cdd58d42d8ab10437ed26708ea Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 14 Jul 2026 11:01:39 +0900 Subject: [PATCH] Show web submissions while fetching context --- .../chat/application/state/actions.ts | 4 + .../application/state/pending-submission.ts | 22 ++ .../chat/application/state/root-reducer.ts | 50 +++- src/features/chat/application/state/store.ts | 1 + .../threads/thread-start-actions.ts | 12 +- .../turns/composer-submit-actions.ts | 77 +++++- .../chat/application/turns/composition.ts | 8 +- .../turns/optimistic-turn-start.ts | 11 +- .../turns/slash-command-execution.ts | 6 +- .../turns/turn-submission-actions.ts | 94 ++++--- .../chat/application/turns/web-submission.ts | 28 ++ .../completed-turn-reconciliation.ts | 25 +- .../chat/domain/thread-stream/provenance.ts | 2 +- .../host/obsidian/web-context.obsidian.ts | 36 ++- .../chat/panel/composer-controller.ts | 2 +- src/features/chat/panel/shell-read-model.ts | 36 ++- .../threads/thread-start-actions.test.ts | 51 ++++ .../turns/composer-submit-actions.test.ts | 141 +++++++++- .../turns/turn-submission-actions.test.ts | 240 +++++++++++++++++- .../completed-turn-reconciliation.test.ts | 18 ++ tests/features/chat/host/web-context.test.ts | 37 ++- .../chat/panel/composer-controller.test.ts | 44 +++- .../blocks-and-messages.test.tsx | 13 + 23 files changed, 874 insertions(+), 84 deletions(-) create mode 100644 src/features/chat/application/state/pending-submission.ts create mode 100644 src/features/chat/application/turns/web-submission.ts diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index 39203ced..ad1fcb2e 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -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 } : {}), }; } diff --git a/src/features/chat/application/state/pending-submission.ts b/src/features/chat/application/state/pending-submission.ts new file mode 100644 index 00000000..53024abe --- /dev/null +++ b/src/features/chat/application/state/pending-submission.ts @@ -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; +} diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 7135e13d..b31279c0 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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), diff --git a/src/features/chat/application/state/store.ts b/src/features/chat/application/state/store.ts index 30eccd04..98600300 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -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], diff --git a/src/features/chat/application/threads/thread-start-actions.ts b/src/features/chat/application/threads/thread-start-actions.ts index 45eb38f6..65422169 100644 --- a/src/features/chat/application/threads/thread-start-actions.ts +++ b/src/features/chat/application/threads/thread-start-actions.ts @@ -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; + startThread: ( + preview?: string, + options?: { syncGoal?: boolean; preservePendingSubmissionId?: string }, + ) => Promise; } 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 { 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); diff --git a/src/features/chat/application/turns/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts index 08a3e6be..e1386176 100644 --- a/src/features/chat/application/turns/composer-submit-actions.ts +++ b/src/features/chat/application/turns/composer-submit-actions.ts @@ -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; composer: { readonly trimmedDraft: string; @@ -48,6 +52,7 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise { 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 { const state = submissionStateSnapshot(host.stateStore.getState()); const turnId = state.activeTurnId; diff --git a/src/features/chat/application/turns/composition.ts b/src/features/chat/application/turns/composition.ts index 0ec462d5..8969df8d 100644 --- a/src/features/chat/application/turns/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -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() { diff --git a/src/features/chat/application/turns/optimistic-turn-start.ts b/src/features/chat/application/turns/optimistic-turn-start.ts index 7222cf8a..fa251a61 100644 --- a/src/features/chat/application/turns/optimistic-turn-start.ts +++ b/src/features/chat/application/turns/optimistic-turn-start.ts @@ -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 } : {}), diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index 91b3e487..61e5d00b 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -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]; diff --git a/src/features/chat/application/turns/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts index 5702079a..927d2693 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -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; - startThread: (preview?: string) => Promise; + startThread: (preview?: string, options?: { preservePendingSubmissionId?: string }) => Promise; notifyActiveThreadIdentityChanged: () => void; resetThreadTurnPresence: (hadTurns: boolean) => void; applyPendingThreadSettings: () => Promise; @@ -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 { - if (!(await host.startThread(text))) return false; +async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string, pendingSubmissionId?: string): Promise { + 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 { + 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; +} diff --git a/src/features/chat/application/turns/web-submission.ts b/src/features/chat/application/turns/web-submission.ts new file mode 100644 index 00000000..7e6279a2 --- /dev/null +++ b/src/features/chat/application/turns/web-submission.ts @@ -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; + } +} diff --git a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts index f2cadc23..badc3f22 100644 --- a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts @@ -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 { const attachmentsByText = new Map(); 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, ): (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, serverUserDialogueFallbackTexts: Set, ): 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, diff --git a/src/features/chat/domain/thread-stream/provenance.ts b/src/features/chat/domain/thread-stream/provenance.ts index aca01845..f91c406c 100644 --- a/src/features/chat/domain/thread-stream/provenance.ts +++ b/src/features/chat/domain/thread-stream/provenance.ts @@ -13,7 +13,7 @@ export type ThreadStreamItemProvenance = } | { source: "localUser"; - channel: "optimistic" | "response"; + channel: "preflight" | "optimistic" | "response"; interaction: "prompt" | "steer" | "approvalResponse" | "userInputResponse"; sourceId?: string; } diff --git a/src/features/chat/host/obsidian/web-context.obsidian.ts b/src/features/chat/host/obsidian/web-context.obsidian.ts index c2684ae0..e87dc264 100644 --- a/src/features/chat/host/obsidian/web-context.obsidian.ts +++ b/src/features/chat/host/obsidian/web-context.obsidian.ts @@ -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 { + 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; - } -} diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 3a638d8c..fb00b37f 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -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, diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index 981ab2bc..9f3ca304 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -34,6 +34,7 @@ interface ChatPanelShellSignals { runtime: Signal; turn: Signal; threadStream: Signal; + pendingSubmission: Signal; requests: Signal; composer: Signal; ui: Signal; @@ -48,6 +49,7 @@ interface ChatPanelShellSignals { threadStreamRollbackCandidate: ReadonlySignal; threadStreamForkCandidates: ReadonlySignal; threadStreamImplementPlanTarget: ReadonlySignal; + webSubmissionPending: ReadonlySignal; threadStreamDisclosures: ReadonlySignal; threadStreamForkMenuItemId: ReadonlySignal; hasThreadTurns: ReadonlySignal; @@ -140,6 +142,7 @@ export interface ChatPanelComposerReadModel { readonly selectedSuggestionIndex: ReadonlySignal; readonly activeThreadId: ReadonlySignal; readonly activeThreadSubagent: ReadonlySignal; + readonly webSubmissionPending: ReadonlySignal; readonly turnBusy: ReadonlySignal; readonly activeTurnId: ReadonlySignal; readonly runtimeSnapshot: ReadonlySignal; @@ -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, diff --git a/tests/features/chat/application/threads/thread-start-actions.test.ts b/tests/features/chat/application/threads/thread-start-actions.test.ts index 50e63fcc..b143818d 100644 --- a/tests/features/chat/application/threads/thread-start-actions.test.ts +++ b/tests/features/chat/application/threads/thread-start-actions.test.ts @@ -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(); + 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" } } }); diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index 54e5b952..f7ef9f70 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -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, 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, + }); +} diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index a6fb1261..037499f4 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -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) { +function resumeThread(stateStore: ReturnType, preservePendingSubmissionId?: string, threadId = "thread") { stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -69,12 +70,13 @@ function resumeThread(stateStore: ReturnType) { 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(); + 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(); + 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(); + 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); diff --git a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts index 839b1abe..360bca32 100644 --- a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts +++ b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts @@ -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"), diff --git a/tests/features/chat/host/web-context.test.ts b/tests/features/chat/host/web-context.test.ts index 1994f18a..a26f5c30 100644 --- a/tests/features/chat/host/web-context.test.ts +++ b/tests/features/chat/host/web-context.test.ts @@ -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(); + 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; +} diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index dd99d94a..89c7afff 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -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"}`, diff --git a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index 4e027433..d3477ad9 100644 --- a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -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: [