fix(composer): make attachment saves explicit editing state

This commit is contained in:
murashit 2026-07-18 07:49:48 +09:00
parent 51994c6777
commit ffb11490ce
11 changed files with 412 additions and 132 deletions

View file

@ -1,32 +0,0 @@
export interface AttachmentInsertionAnchor {
readonly panelTargetRevision: number;
readonly threadId: string | null;
readonly draft: string;
readonly start: number;
readonly end: number;
}
export function attachmentInsertionAnchorMatches(
anchor: AttachmentInsertionAnchor,
target:
| AttachmentInsertionAnchor
| {
panelTargetRevision: number;
threadId: string | null;
draft: string;
selection: { start: number; end: number } | null;
},
): boolean {
if (anchor.panelTargetRevision !== target.panelTargetRevision || anchor.threadId !== target.threadId || anchor.draft !== target.draft) {
return false;
}
if (!("selection" in target)) return anchor.start === target.start && anchor.end === target.end;
return target.selection === null || (anchor.start === target.selection.start && anchor.end === target.selection.end);
}
export function attachmentInsertionAnchorAfterInsertion(
anchor: AttachmentInsertionAnchor,
insertion: { value: string; cursor: number },
): AttachmentInsertionAnchor {
return { ...anchor, draft: insertion.value, start: insertion.cursor, end: insertion.cursor };
}

View file

@ -131,6 +131,7 @@ type ActiveThreadLifetime =
interface ChatComposerState {
readonly draft: string;
readonly pendingAttachmentSaveIds: readonly number[];
readonly suggestSelected: number;
readonly suggestions: readonly ComposerSuggestion[];
readonly suggestionsDismissedSignature: string | null;
@ -208,6 +209,16 @@ type TurnAction =
| TurnStartFailedAction;
type ComposerAction =
| {
type: "composer/attachment-save-started";
saveId: number;
draft: string;
}
| {
type: "composer/attachment-save-settled";
saveId: number;
draft?: string;
}
| {
type: "composer/draft-set";
draft: string;
@ -788,6 +799,18 @@ function reduceRuntimeSlice(state: ChatRuntimeState, action: ChatSliceAction): C
function reduceComposerSlice(state: ChatComposerState, action: ChatSliceAction): ChatComposerState {
switch (action.type) {
case "composer/attachment-save-started":
return patchObject(state, {
draft: action.draft,
pendingAttachmentSaveIds: [...state.pendingAttachmentSaveIds, action.saveId],
suggestions: [],
suggestSelected: 0,
});
case "composer/attachment-save-settled":
return patchObject(state, {
...(action.draft === undefined ? {} : { draft: action.draft }),
pendingAttachmentSaveIds: state.pendingAttachmentSaveIds.filter((saveId) => saveId !== action.saveId),
});
case "composer/draft-set":
return patchObject(state, {
draft: action.draft,
@ -878,6 +901,7 @@ function initialRequestState(): ChatRequestState {
function initialComposerState(): ChatComposerState {
return {
draft: "",
pendingAttachmentSaveIds: [],
suggestSelected: 0,
suggestions: [],
suggestionsDismissedSignature: null,

View file

@ -1,10 +1,5 @@
import { isComposerSendKey, type SendShortcut } from "../../../domain/input/send-shortcut";
import { OwnerLifetime } from "../../../shared/runtime/owner-lifetime";
import {
type AttachmentInsertionAnchor,
attachmentInsertionAnchorAfterInsertion,
attachmentInsertionAnchorMatches,
} from "../application/composer/attachment-insertion-anchor";
import {
type ComposerAttachment,
type ComposerAttachmentHandler,
@ -45,6 +40,7 @@ import {
composerHasFocus,
composerInsertionSource,
composerRangeInsertionSource,
composerSelectionSource,
composerSuggestionSignatureFromElement,
composerTextBeforeCursor,
composerTransferHasFiles,
@ -84,9 +80,8 @@ export class ChatComposerController {
private readonly lifetime = new OwnerLifetime();
private composer: HTMLTextAreaElement | null = null;
private attachments: ComposerAttachment[] = [];
private pendingAttachmentTransfers = new Set<Promise<void>>();
private pendingAttachmentInsertions = new Set<PendingAttachmentInsertion>();
private submitAfterAttachmentTransfersActive = false;
private pendingAttachmentSaveId = 0;
private pendingAttachmentSaves = new Map<number, PendingAttachmentSave>();
private activeNoteContextSnapshots: ActiveNoteContextReference[] = [];
private selectionContextSnapshots: SelectionContextReference[] = [];
private pendingSelection: ComposerPendingSelection | null = null;
@ -117,13 +112,14 @@ export class ChatComposerController {
busy: model.turnBusy,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
sendDisabled: model.attachmentSavePending,
webSubmissionCancellable: model.webSubmissionCancellable,
normalPlaceholder: projection.placeholder,
suggestions: model.suggestions,
selectedSuggestionIndex: model.selectedSuggestionIndex,
pendingSelection: this.pendingSelection,
onPendingSelectionApplied: this.clearPendingSelection,
callbacks: this.composerCallbacks(actions),
callbacks: this.composerCallbacks(actions, model),
meta: projection.meta,
onComposer: this.setComposerElement,
};
@ -164,6 +160,8 @@ export class ChatComposerController {
dispose(): void {
this.lifetime.dispose();
for (const pending of this.pendingAttachmentSaves.values()) this.settlePendingAttachmentSave(pending);
this.pendingAttachmentSaves.clear();
this.composer = null;
this.options.noteCandidateProvider.dispose();
this.options.contextReferenceProvider.dispose();
@ -335,7 +333,7 @@ export class ChatComposerController {
this.pruneActiveNoteContextSnapshots(insertion.value);
this.pruneSelectionContextSnapshots(insertion.value);
this.pendingSelection = { value: insertion.value, cursor: insertion.cursor };
this.pendingSelection = collapsedComposerSelection(insertion.value, insertion.cursor);
this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true });
this.options.onDraftChange();
applyComposerInsertionToElement(this.composer, insertion.cursor);
@ -354,81 +352,89 @@ export class ChatComposerController {
const handler = this.options.attachmentHandler;
if (!handler) return;
const pendingInsertion = this.pendingAttachmentInsertion();
const transfer = this.saveTransferredFiles(handler, files, pendingInsertion);
this.pendingAttachmentTransfers.add(transfer);
void transfer.finally(() => {
this.pendingAttachmentTransfers.delete(transfer);
pendingInsertion.transferCount -= 1;
if (pendingInsertion.transferCount === 0) this.pendingAttachmentInsertions.delete(pendingInsertion);
const pending = this.startPendingAttachmentSave(files);
this.pendingAttachmentSaves.set(pending.id, pending);
void this.saveTransferredFiles(handler, files, pending).finally(() => {
this.pendingAttachmentSaves.delete(pending.id);
});
}
private async saveTransferredFiles(
handler: ComposerAttachmentHandler,
files: readonly File[],
pendingInsertion: PendingAttachmentInsertion,
pending: PendingAttachmentSave,
): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
try {
const attachments = await handler.saveFiles(files);
if (!this.lifetime.isCurrent(lifetime)) return;
if (attachments.length === 0) return;
this.insertAttachmentMarkers(attachments, pendingInsertion);
this.completePendingAttachmentSave(pending, attachments);
} catch (error) {
if (!this.lifetime.isCurrent(lifetime)) return;
this.settlePendingAttachmentSave(pending);
this.options.onAttachmentError?.(error instanceof Error ? error.message : String(error));
}
}
private pendingAttachmentInsertion(): PendingAttachmentInsertion {
const anchor = this.attachmentInsertionAnchor();
const pendingInsertion = [...this.pendingAttachmentInsertions].find((pending) =>
attachmentInsertionAnchorMatches(pending.anchor, anchor),
);
if (pendingInsertion) {
pendingInsertion.transferCount += 1;
return pendingInsertion;
}
const next = { anchor, transferCount: 1 };
this.pendingAttachmentInsertions.add(next);
return next;
}
private attachmentInsertionAnchor(): AttachmentInsertionAnchor {
private startPendingAttachmentSave(files: readonly File[]): PendingAttachmentSave {
const id = ++this.pendingAttachmentSaveId;
const draft = this.state.composer.draft;
const source = composerRangeInsertionSource(this.composer);
const selection = source && source.value === draft ? source : null;
return {
const placeholder = pendingAttachmentPlaceholder(id, files.length);
const insertion = applyAttachmentMarkerInsertion(draft, selection?.start ?? draft.length, selection?.end ?? draft.length, [
placeholder,
]);
const placeholderOffset = insertion.insertedText.indexOf(placeholder);
const pending = {
id,
destination: pendingAttachmentDestination(id),
displacedText: selection ? draft.slice(selection.start, selection.end) : "",
syntheticPrefix: insertion.insertedText.slice(0, placeholderOffset),
syntheticSuffix: insertion.insertedText.slice(placeholderOffset + placeholder.length),
panelTargetRevision: this.state.panelTargetRevision,
threadId: panelThreadId(this.state),
draft,
start: selection?.start ?? draft.length,
end: selection?.end ?? draft.length,
};
}
private insertAttachmentMarkers(attachments: readonly ComposerAttachment[], pendingInsertion: PendingAttachmentInsertion): void {
const markers = attachments.map((attachment) => attachment.marker);
if (markers.length === 0) return;
const current = this.attachmentInsertionAnchor();
if (!attachmentInsertionAnchorMatches(pendingInsertion.anchor, current)) return;
const insertion = applyAttachmentMarkerInsertion(
pendingInsertion.anchor.draft,
pendingInsertion.anchor.start,
pendingInsertion.anchor.end,
markers,
);
pendingInsertion.anchor = attachmentInsertionAnchorAfterInsertion(pendingInsertion.anchor, insertion);
this.attachments = [...this.attachments, ...attachments];
this.pruneAttachments(insertion.value);
this.pruneActiveNoteContextSnapshots(insertion.value);
this.pruneSelectionContextSnapshots(insertion.value);
this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true });
this.pendingSelection = collapsedComposerSelection(insertion.value, insertion.cursor);
this.dispatch({ type: "composer/attachment-save-started", saveId: id, draft: insertion.value });
this.options.onDraftChange();
applyComposerInsertionToElement(this.composer, insertion.cursor);
return pending;
}
private completePendingAttachmentSave(pending: PendingAttachmentSave, attachments: readonly ComposerAttachment[]): void {
const markers = attachments.map((attachment) => attachment.marker);
const state = this.state;
if (
state.panelTargetRevision !== pending.panelTargetRevision ||
panelThreadId(state) !== pending.threadId ||
!state.composer.pendingAttachmentSaveIds.includes(pending.id)
) {
this.settlePendingAttachmentSave(pending);
return;
}
const replacement =
markers.length === 0
? replacePendingAttachmentWithOriginalText(state.composer.draft, pending)
: replacePendingAttachmentPlaceholder(state.composer.draft, pending, markers);
this.pendingSelection = adjustedComposerSelection(composerSelectionSource(this.composer), state.composer.draft, replacement);
this.attachments = [...this.attachments, ...attachments];
this.pruneAttachments(replacement.value);
this.pruneActiveNoteContextSnapshots(replacement.value);
this.pruneSelectionContextSnapshots(replacement.value);
this.dispatch({ type: "composer/attachment-save-settled", saveId: pending.id, draft: replacement.value });
this.options.onDraftChange();
}
private settlePendingAttachmentSave(pending: PendingAttachmentSave): void {
const state = this.state;
if (!state.composer.pendingAttachmentSaveIds.includes(pending.id)) return;
const replacement = replacePendingAttachmentWithOriginalText(state.composer.draft, pending);
this.pendingSelection = adjustedComposerSelection(composerSelectionSource(this.composer), state.composer.draft, replacement);
this.dispatch({ type: "composer/attachment-save-settled", saveId: pending.id, draft: replacement.value });
this.options.onDraftChange();
}
private dismissSuggestions(): void {
@ -499,30 +505,21 @@ export class ChatComposerController {
}
private activeAttachments(text: string): readonly ComposerAttachment[] {
return this.attachments.filter((attachment) => text.includes(attachment.marker));
return this.attachments
.filter((attachment) => text.includes(attachment.marker))
.sort((left, right) => text.indexOf(left.marker) - text.indexOf(right.marker));
}
private pruneAttachments(text: string): void {
this.attachments = [...this.activeAttachments(text)];
}
private async submitAfterAttachmentTransfers(actions: ChatComposerRenderActions): Promise<void> {
if (this.submitAfterAttachmentTransfersActive) return;
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
this.submitAfterAttachmentTransfersActive = true;
try {
while (this.pendingAttachmentTransfers.size > 0) {
await Promise.allSettled([...this.pendingAttachmentTransfers]);
}
if (!this.lifetime.isCurrent(lifetime)) return;
actions.submit();
} finally {
this.submitAfterAttachmentTransfersActive = false;
}
private attachmentSaveBlocksSubmit(model: ChatPanelComposerModel): boolean {
if (this.state.composer.pendingAttachmentSaveIds.length === 0) return false;
return !(this.options.canInterrupt(model) && this.state.composer.draft.trim().length === 0);
}
private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks {
private composerCallbacks(actions: ChatComposerRenderActions, model: ChatPanelComposerModel): ComposerCallbacks {
return {
onInput: (value) => {
this.handleInput(value);
@ -539,7 +536,7 @@ export class ChatComposerController {
}
if (isComposerSendKey(event, this.options.sendShortcut())) {
event.preventDefault();
void this.submitAfterAttachmentTransfers(actions);
if (!this.attachmentSaveBlocksSubmit(model)) actions.submit();
}
},
onPaste: (event) => {
@ -564,8 +561,8 @@ export class ChatComposerController {
actions.submit();
return;
}
if (this.state.pendingSubmission) return;
void this.submitAfterAttachmentTransfers(actions);
if (this.state.pendingSubmission || this.attachmentSaveBlocksSubmit(model)) return;
actions.submit();
},
onHeightChange: () => {
this.options.onHeightChange();
@ -589,9 +586,89 @@ export class ChatComposerController {
}
}
interface PendingAttachmentInsertion {
anchor: AttachmentInsertionAnchor;
transferCount: number;
interface PendingAttachmentSave {
readonly id: number;
readonly destination: string;
readonly displacedText: string;
readonly syntheticPrefix: string;
readonly syntheticSuffix: string;
readonly panelTargetRevision: number;
readonly threadId: string | null;
}
function pendingAttachmentPlaceholder(id: number, fileCount: number): string {
const label = fileCount === 1 ? "Saving attachment…" : `Saving ${String(fileCount)} attachments…`;
return `[${label}](${pendingAttachmentDestination(id)})`;
}
function pendingAttachmentDestination(id: number): string {
return `codex-panel-pending-attachment:${String(id)}`;
}
interface DraftReplacement {
readonly value: string;
readonly start: number;
readonly end: number;
readonly replacementLength: number;
}
function replacePendingAttachmentPlaceholder(draft: string, pending: PendingAttachmentSave, markers: readonly string[]): DraftReplacement {
const range = pendingAttachmentLinkRange(draft, pending.destination);
return range ? replaceDraftRange(draft, range.start, range.end, markers.join("\n")) : unchangedDraftReplacement(draft);
}
function replacePendingAttachmentWithOriginalText(draft: string, pending: PendingAttachmentSave): DraftReplacement {
const range = pendingAttachmentLinkRange(draft, pending.destination);
if (!range) return unchangedDraftReplacement(draft);
const prefixStart = range.start - pending.syntheticPrefix.length;
const start = prefixStart >= 0 && draft.slice(prefixStart, range.start) === pending.syntheticPrefix ? prefixStart : range.start;
const suffixEnd = range.end + pending.syntheticSuffix.length;
const end = draft.slice(range.end, suffixEnd) === pending.syntheticSuffix ? suffixEnd : range.end;
return replaceDraftRange(draft, start, end, pending.displacedText);
}
function pendingAttachmentLinkRange(draft: string, destination: string): { start: number; end: number } | null {
const suffix = `](${destination})`;
const suffixStart = draft.indexOf(suffix);
if (suffixStart < 0) return null;
const start = draft.lastIndexOf("[", suffixStart);
return start < 0 ? null : { start, end: suffixStart + suffix.length };
}
function replaceDraftRange(draft: string, start: number, end: number, replacement: string): DraftReplacement {
return {
value: `${draft.slice(0, start)}${replacement}${draft.slice(end)}`,
start,
end,
replacementLength: replacement.length,
};
}
function unchangedDraftReplacement(draft: string): DraftReplacement {
return { value: draft, start: draft.length, end: draft.length, replacementLength: 0 };
}
function adjustedComposerSelection(
selection: ReturnType<typeof composerSelectionSource>,
draft: string,
replacement: DraftReplacement,
): ComposerPendingSelection | null {
if (!selection || selection.value !== draft || replacement.value === draft) return null;
const adjust = (position: number): number => {
if (position <= replacement.start) return position;
if (position >= replacement.end) return position + replacement.replacementLength - (replacement.end - replacement.start);
return replacement.start + replacement.replacementLength;
};
return {
value: replacement.value,
start: adjust(selection.start),
end: adjust(selection.end),
direction: selection.direction,
};
}
function collapsedComposerSelection(value: string, cursor: number): ComposerPendingSelection {
return { value, start: cursor, end: cursor, direction: "none" };
}
function applyAttachmentMarkerInsertion(
@ -599,7 +676,7 @@ function applyAttachmentMarkerInsertion(
start: number,
end: number,
markers: readonly string[],
): { value: string; cursor: number } {
): { value: string; cursor: number; insertedText: string } {
const prefix = value.slice(0, start);
const suffix = value.slice(end);
const before = prefix && !prefix.endsWith("\n") ? "\n" : "";
@ -609,5 +686,6 @@ function applyAttachmentMarkerInsertion(
return {
value: nextValue,
cursor: prefix.length + before.length + inserted.length,
insertedText: `${before}${inserted}${after}`,
};
}

View file

@ -14,6 +14,10 @@ export interface ComposerElementRangeInsertion {
end: number;
}
export interface ComposerElementSelection extends ComposerElementRangeInsertion {
direction: "forward" | "backward" | "none";
}
export function focusComposer(composer: HTMLTextAreaElement | null, options: { preventScroll?: boolean } = {}): void {
composer?.focus(options);
}
@ -49,6 +53,16 @@ export function composerRangeInsertionSource(composer: HTMLTextAreaElement | nul
};
}
export function composerSelectionSource(composer: HTMLTextAreaElement | null): ComposerElementSelection | null {
if (!composer) return null;
return {
value: composer.value,
start: composer.selectionStart,
end: composer.selectionEnd,
direction: composer.selectionDirection,
};
}
export function composerBoundaryScrollActionFromElement(
event: KeyboardEvent,
composer: HTMLTextAreaElement | null,

View file

@ -58,6 +58,7 @@ export interface ChatPanelComposerModel {
readonly sideChatActive: boolean;
readonly sideChatSourceTitle: string | null;
readonly draft: ChatState["composer"]["draft"];
readonly attachmentSavePending: boolean;
readonly suggestions: ChatState["composer"]["suggestions"];
readonly selectedSuggestionIndex: ChatState["composer"]["suggestSelected"];
readonly activeThreadId: string | null;
@ -135,6 +136,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
sideChatActive: lifetime?.kind === "ephemeral",
sideChatSourceTitle: lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null,
draft: state.composer.draft,
attachmentSavePending: state.composer.pendingAttachmentSaveIds.length > 0,
suggestions: state.composer.suggestions,
selectedSuggestionIndex: state.composer.suggestSelected,
activeThreadId,

View file

@ -50,11 +50,6 @@ export function restoreComposerSelection(composer: HTMLTextAreaElement | null, s
composer.setSelectionRange(selection.start, selection.end, selection.direction);
}
export function restoreComposerCursor(composer: HTMLTextAreaElement | null, cursor: number): void {
if (!composer) return;
composer.setSelectionRange(cursor, cursor);
}
export function observeComposerMetaStatusOverflow(status: HTMLElement): () => void {
const win = status.win;
let frame = 0;

View file

@ -4,12 +4,12 @@ import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { IconButton } from "../../../shared/obsidian/components.obsidian";
import {
type ComposerMetaPickerState,
type ComposerTextSelection,
closeComposerMetaPickerOnOutsidePointer,
composerMetaPickerState,
observeComposerMetaStatusOverflow,
preserveComposerSelection,
renderComposerMetaIcon,
restoreComposerCursor,
restoreComposerSelection,
scrollComposerSuggestionIntoView,
syncComposerHeight,
@ -25,9 +25,8 @@ export interface ComposerSuggestion {
suffixOnInsert?: string;
}
export interface ComposerPendingSelection {
export interface ComposerPendingSelection extends ComposerTextSelection {
value: string;
cursor: number;
}
export interface ComposerMetaViewModel {
@ -89,6 +88,7 @@ export interface ComposerShellProps {
busy: boolean;
canInterrupt: boolean;
submissionDisabled: boolean;
sendDisabled: boolean;
webSubmissionCancellable: boolean;
normalPlaceholder: string;
meta: ComposerMetaViewModel;
@ -106,6 +106,7 @@ export function ComposerShell({
busy,
canInterrupt,
submissionDisabled,
sendDisabled,
webSubmissionCancellable,
normalPlaceholder,
meta,
@ -148,10 +149,10 @@ export function ComposerShell({
});
useLayoutEffect(() => {
if (!pendingSelection) return;
if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor);
if (pendingSelection.value === draft) restoreComposerSelection(composerRef.current, pendingSelection);
onPendingSelectionApplied?.();
}, [draft, pendingSelection, onPendingSelectionApplied]);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, webSubmissionCancellable);
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, sendDisabled, webSubmissionCancellable);
const composerLocked = submissionDisabled;
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
@ -479,6 +480,7 @@ function composerSendMode(
canInterrupt: boolean,
draft: string,
submissionDisabled: boolean,
sendDisabled: boolean,
webSubmissionCancellable: boolean,
): ComposerSendMode {
if (webSubmissionCancellable) {
@ -497,7 +499,7 @@ function composerSendMode(
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
disabled: submissionDisabled || (busy && !canInterrupt),
disabled: submissionDisabled || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
canInterrupt,
};
}

View file

@ -542,7 +542,7 @@ describe("ChatComposerController", () => {
expect(controller.captureInputSnapshot().attachments).toEqual([]);
});
it("does not insert a saved attachment after the composer selection changes", async () => {
it("keeps a pending attachment at its placeholder while the draft is edited", async () => {
const saved = deferred<ComposerAttachment[]>();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
@ -554,12 +554,13 @@ describe("ChatComposerController", () => {
composer(parent).setSelectionRange(6, 6);
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "first.png", { type: "image/png" })]));
composer(parent).setSelectionRange(0, 0);
setTextAreaValue(composer(parent), `edited ${composer(parent).value}`);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
saved.resolve([attachmentFixture("first")]);
await flushComposerAttachment();
expect(composer(parent).value).toBe("before after");
expect(controller.captureInputSnapshot().attachments).toEqual([]);
expect(composer(parent).value).toBe("edited before\n![[Codex Attachments/first.png]]\n after");
expect(controller.captureInputSnapshot().attachments).toEqual([attachmentFixture("first")]);
});
it("inserts multiple transfers that began at the same draft anchor", async () => {
@ -587,8 +588,8 @@ describe("ChatComposerController", () => {
first.resolve([attachmentFixture("first")]);
await flushComposerAttachment();
expect(composer(parent).value).toBe("note\n![[Codex Attachments/second.png]]\n![[Codex Attachments/first.png]]");
expect(controller.captureInputSnapshot().attachments.map((attachment) => attachment.name)).toEqual(["second", "first"]);
expect(composer(parent).value).toBe("note\n![[Codex Attachments/first.png]]\n![[Codex Attachments/second.png]]");
expect(controller.captureInputSnapshot().attachments.map((attachment) => attachment.name)).toEqual(["first", "second"]);
});
it("preserves pasted image attachments when connection exit restores a cancellable web draft", async () => {
@ -708,7 +709,7 @@ describe("ChatComposerController", () => {
expect(dataTransfer.dropEffect).toBe("copy");
});
it("waits for pending attachment saves before submitting", async () => {
it("keeps the composer editable but requires a new Send after attachment saves settle", async () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const attachment: ComposerAttachment = {
@ -757,6 +758,10 @@ describe("ChatComposerController", () => {
renderShell();
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.disabled).toBe(true);
expect(composer(parent).readOnly).toBe(false);
expect(composer(parent).value).toContain("Saving attachment…");
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce();
@ -768,9 +773,180 @@ describe("ChatComposerController", () => {
await flushComposerAttachment();
expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]");
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__send")?.disabled).toBe(false);
expect(submit).not.toHaveBeenCalled();
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
expect(submit).toHaveBeenCalledOnce();
});
it("removes a failed attachment placeholder without submitting the draft", async () => {
const saved = deferred<ComposerAttachment[]>();
const submit = vi.fn();
const onAttachmentError = vi.fn();
const { controller, parent, renderShell, stateStore } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
onAttachmentError,
},
renderActions: { submit },
});
renderShell();
controller.setDraft("Keep this draft");
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
saved.reject(new Error("Attachment save failed."));
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("Keep this draft");
expect(stateStore.getState().composer.pendingAttachmentSaveIds).toEqual([]);
expect(onAttachmentError).toHaveBeenCalledWith("Attachment save failed.");
expect(submit).not.toHaveBeenCalled();
});
it("restores selected draft text and caret when attachment saving fails", async () => {
const saved = deferred<ComposerAttachment[]>();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
},
});
controller.setDraft("keep selected text");
renderShell();
composer(parent).setSelectionRange(5, 13);
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
composer(parent).setSelectionRange(0, 0);
saved.reject(new Error("Attachment save failed."));
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("keep selected text");
expect(composer(parent).selectionStart).toBe(0);
expect(composer(parent).selectionEnd).toBe(0);
});
it("settles an attachment after its placeholder label is edited", async () => {
const saved = deferred<ComposerAttachment[]>();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
},
});
controller.setDraft("Keep this draft");
renderShell();
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
composer(parent).setSelectionRange(0, 0);
saved.resolve([
{
kind: "image",
name: "diagram",
path: "Codex Attachments/diagram.png",
marker: "![[Codex Attachments/diagram.png]]",
},
]);
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("Keep this draft\n![[Codex Attachments/diagram.png]]");
expect(composer(parent).value).not.toContain("codex-panel-pending-attachment:");
expect(composer(parent).selectionStart).toBe(0);
});
it("removes an edited attachment placeholder when saving fails", async () => {
const saved = deferred<ComposerAttachment[]>();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
},
});
controller.setDraft("Keep this draft");
renderShell();
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
saved.reject(new Error("Attachment save failed."));
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("Keep this draft");
expect(composer(parent).value).not.toContain("codex-panel-pending-attachment:");
});
it("restores selected text without synthetic separators after the placeholder label is edited", async () => {
const saved = deferred<ComposerAttachment[]>();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
},
});
controller.setDraft("keep selected text");
renderShell();
composer(parent).setSelectionRange(5, 13);
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
composer(parent).setSelectionRange(0, 0);
saved.reject(new Error("Attachment save failed."));
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("keep selected text");
expect(composer(parent).selectionStart).toBe(0);
});
it("allows an interrupt after a pending attachment placeholder is removed", () => {
const saved = deferred<ComposerAttachment[]>();
const submit = vi.fn();
const { controller, parent, renderShell } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
canInterrupt: () => true,
},
renderActions: { submit },
});
renderShell();
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
controller.setDraft("");
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
expect(sendButton?.disabled).toBe(false);
sendButton?.click();
expect(submit).toHaveBeenCalledOnce();
});
it("does not reinsert a saved attachment after the user removes its placeholder", async () => {
const saved = deferred<ComposerAttachment[]>();
const attachment: ComposerAttachment = {
kind: "image",
name: "diagram",
path: "Codex Attachments/diagram.png",
marker: "![[Codex Attachments/diagram.png]]",
};
const { controller, parent, renderShell, stateStore } = composerControllerFixture({
controller: {
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
},
});
renderShell();
controller.setDraft("Keep this draft");
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
controller.setDraft("Keep this edited draft");
saved.resolve([attachment]);
await flushComposerAttachment();
await flushComposerAttachment();
expect(composer(parent).value).toBe("Keep this edited draft");
expect(stateStore.getState().composer.pendingAttachmentSaveIds).toEqual([]);
});
it("does not submit or apply saved attachments after disposal", async () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");

View file

@ -292,6 +292,7 @@ function shellParts(
busy: false,
canInterrupt: false,
submissionDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],

View file

@ -124,6 +124,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
busy: false,
canInterrupt: false,
submissionDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],

View file

@ -24,6 +24,7 @@ function mountComposerShell(
meta?: ComposerMetaViewModel,
webSubmissionPending = false,
webSubmissionCancellable = webSubmissionPending,
sendDisabled = false,
): { composer: HTMLTextAreaElement } {
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
renderUiRoot(
@ -34,6 +35,7 @@ function mountComposerShell(
busy,
canInterrupt,
submissionDisabled: webSubmissionPending,
sendDisabled,
webSubmissionCancellable,
normalPlaceholder,
suggestions,
@ -400,6 +402,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
@ -439,6 +442,7 @@ describe("ComposerShell decisions", () => {
busy: false,
canInterrupt: false,
submissionDisabled: false,
sendDisabled: false,
webSubmissionCancellable: false,
normalPlaceholder: "Ask Codex...",
suggestions: [],
@ -538,6 +542,21 @@ describe("ComposerShell decisions", () => {
expect(sendButton?.dataset["icon"]).toBe("corner-down-right");
});
it("keeps interrupt enabled while a send-only attachment barrier is active", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
mountComposerShell(parent, "view", "", true, true, "Ask Codex...", [], 0, callbacks, undefined, false, false, true);
let sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
expect(sendButton?.disabled).toBe(false);
mountComposerShell(parent, "view", "steer later", true, true, "Ask Codex...", [], 0, callbacks, undefined, false, false, true);
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
expect(sendButton?.disabled).toBe(true);
});
it("renders an enabled cancel control while a web import locks composer input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();