From ffb11490ce65e67d7ec7d1723803797ca4646be0 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 07:49:48 +0900 Subject: [PATCH] fix(composer): make attachment saves explicit editing state --- .../composer/attachment-insertion-anchor.ts | 32 --- .../chat/application/state/root-reducer.ts | 24 ++ .../chat/panel/composer-controller.ts | 242 ++++++++++++------ .../chat/panel/composer-element.dom.ts | 14 + src/features/chat/panel/shell-selectors.ts | 2 + src/features/chat/ui/composer.dom.ts | 5 - src/features/chat/ui/composer.tsx | 14 +- .../chat/panel/composer-controller.test.ts | 190 +++++++++++++- tests/features/chat/panel/shell.test.tsx | 1 + .../chat/panel/toolbar-archive-state.test.tsx | 1 + tests/features/chat/ui/composer.test.ts | 19 ++ 11 files changed, 412 insertions(+), 132 deletions(-) delete mode 100644 src/features/chat/application/composer/attachment-insertion-anchor.ts diff --git a/src/features/chat/application/composer/attachment-insertion-anchor.ts b/src/features/chat/application/composer/attachment-insertion-anchor.ts deleted file mode 100644 index c27c4d8f..00000000 --- a/src/features/chat/application/composer/attachment-insertion-anchor.ts +++ /dev/null @@ -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 }; -} diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 462fcc9a..08981375 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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, diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index d2d62f83..bf7f8d53 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -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>(); - private pendingAttachmentInsertions = new Set(); - private submitAfterAttachmentTransfersActive = false; + private pendingAttachmentSaveId = 0; + private pendingAttachmentSaves = new Map(); 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 { 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 { - 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, + 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}`, }; } diff --git a/src/features/chat/panel/composer-element.dom.ts b/src/features/chat/panel/composer-element.dom.ts index f87d6983..3a76f7a2 100644 --- a/src/features/chat/panel/composer-element.dom.ts +++ b/src/features/chat/panel/composer-element.dom.ts @@ -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, diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts index b2e5bf98..b5a28574 100644 --- a/src/features/chat/panel/shell-selectors.ts +++ b/src/features/chat/panel/shell-selectors.ts @@ -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, diff --git a/src/features/chat/ui/composer.dom.ts b/src/features/chat/ui/composer.dom.ts index e50a6ac2..555acab4 100644 --- a/src/features/chat/ui/composer.dom.ts +++ b/src/features/chat/ui/composer.dom.ts @@ -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; diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 7e7b3d0b..a58f8a4f 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -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, }; } diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 480b2cec..0d0cf501 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -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(); 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(".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(".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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(".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(); + 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"); diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 38bec3cf..59a9a948 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -292,6 +292,7 @@ function shellParts( busy: false, canInterrupt: false, submissionDisabled: false, + sendDisabled: false, webSubmissionCancellable: false, normalPlaceholder: "Ask Codex...", suggestions: [], diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index cbd46f35..42902b5a 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -124,6 +124,7 @@ function shellParts(store: ReturnType, toolbarPanel busy: false, canInterrupt: false, submissionDisabled: false, + sendDisabled: false, webSubmissionCancellable: false, normalPlaceholder: "Ask Codex...", suggestions: [], diff --git a/tests/features/chat/ui/composer.test.ts b/tests/features/chat/ui/composer.test.ts index 7402ed74..094a68a2 100644 --- a/tests/features/chat/ui/composer.test.ts +++ b/tests/features/chat/ui/composer.test.ts @@ -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(".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(".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();