mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Show web submissions while fetching context
This commit is contained in:
parent
2c254339cf
commit
00402e0d56
23 changed files with 874 additions and 84 deletions
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/features/chat/application/state/pending-submission.ts
Normal file
22
src/features/chat/application/state/pending-submission.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
28
src/features/chat/application/turns/web-submission.ts
Normal file
28
src/features/chat/application/turns/web-submission.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export type ThreadStreamItemProvenance =
|
|||
}
|
||||
| {
|
||||
source: "localUser";
|
||||
channel: "optimistic" | "response";
|
||||
channel: "preflight" | "optimistic" | "response";
|
||||
interaction: "prompt" | "steer" | "approvalResponse" | "userInputResponse";
|
||||
sourceId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" } } });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}`,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue