mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(composer): make attachment saves explicit editing state
This commit is contained in:
parent
51994c6777
commit
ffb11490ce
11 changed files with 412 additions and 132 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ function shellParts(
|
|||
busy: false,
|
||||
canInterrupt: false,
|
||||
submissionDisabled: false,
|
||||
sendDisabled: false,
|
||||
webSubmissionCancellable: false,
|
||||
normalPlaceholder: "Ask Codex...",
|
||||
suggestions: [],
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue