diff --git a/README.md b/README.md index e8527a26..62155403 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ The composer understands Obsidian and Codex context together. Wikilink suggestio Use the `@active-note` completion when you want the composer to insert the last active note as a wikilink. Use the `@selection` completion to insert the selected range from the last active Markdown note as `[[note]] (Lx:Cy-Lx:Cy)`; Codex Panel keeps that completed range as the visible prompt text and sends the selected text as additional turn context. +Paste or drop files into the composer to save them under the configured attachment folder. Images are inserted as Obsidian embeds; other files are inserted as wikilinks and sent to Codex as file mentions. + Completions are also available for slash commands, enabled skills such as `$skill-name`, recent threads, models, and supported reasoning efforts. Use `/help` in the composer for the current slash command list. You can reference another non-archived thread without leaving the active panel, or use `/refer` when you want Codex to include recent turns from another thread in a message. While a turn is running, the panel streams assistant messages, reasoning, commands, tool calls, hooks, file changes, plan updates, and agent activity. You can answer Codex questions, approve commands, respond to MCP and file requests, send steering messages, or interrupt the turn when the composer is empty. If a thread has an active Codex goal, the panel shows it above the message stream and lets you edit, pause, resume, or clear it. diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 3aa4d91d..6ba4f3f8 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -79,12 +79,14 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread } function userInputText(content: readonly AppServerUserInput[]): string { - const hasText = content.some((item) => item.type === "text" && item.text.length > 0); + const textItems = content.filter((item) => item.type === "text"); + const hasText = textItems.some((item) => item.text.length > 0); + const textIncludes = (value: string) => value.length > 0 && textItems.some((item) => item.text.includes(value)); return content .map((item) => { if (item.type === "text") return item.text; - if (item.type === "localImage") return `[local image] ${item.path}`; - if (item.type === "image") return `[image] ${item.url}`; + if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`; + if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`; if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`; return hasText ? "" : `[$${item.name}] ${item.path}`; }) diff --git a/src/features/chat/application/composer/attachments.ts b/src/features/chat/application/composer/attachments.ts new file mode 100644 index 00000000..041d4ff8 --- /dev/null +++ b/src/features/chat/application/composer/attachments.ts @@ -0,0 +1,47 @@ +import { type CodexInput, type CodexInputItem, codexTextInputWithAttachments, type RequestMention } from "../../../../domain/chat/input"; + +type ComposerAttachmentKind = "image" | "file"; + +export interface ComposerAttachment { + readonly kind: ComposerAttachmentKind; + readonly name: string; + readonly path: string; + readonly marker: string; +} + +export interface ComposerAttachmentHandler { + saveFiles(files: readonly File[]): Promise; +} + +export function codexInputWithComposerAttachments(text: string, input: CodexInput, attachments: readonly ComposerAttachment[]): CodexInput { + const activeAttachments = attachments.filter((attachment) => text.includes(attachment.marker)); + if (activeAttachments.length === 0) return input; + + return codexTextInputWithAttachments(text, [...input, ...inputItemsForAttachments(input, activeAttachments)]); +} + +function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: readonly ComposerAttachment[]): CodexInputItem[] { + const seenMentionPaths = new Set(input.flatMap((item) => (item.type === "mention" ? [item.path] : []))); + const seenLocalImagePaths = new Set(input.flatMap((item) => (item.type === "localImage" ? [item.path] : []))); + const items: CodexInputItem[] = []; + + for (const attachment of attachments) { + if (!seenMentionPaths.has(attachment.path)) { + seenMentionPaths.add(attachment.path); + items.push(mentionInputItem(attachment)); + } + if (attachment.kind === "image" && !seenLocalImagePaths.has(attachment.path)) { + seenLocalImagePaths.add(attachment.path); + items.push({ type: "localImage", path: attachment.path }); + } + } + return items; +} + +function mentionInputItem(attachment: ComposerAttachment): RequestMention & { type: "mention" } { + return { + type: "mention", + name: attachment.name, + path: attachment.path, + }; +} diff --git a/src/features/chat/application/composer/context-references.ts b/src/features/chat/application/composer/context-references.ts index 9af8d51a..a46b55f0 100644 --- a/src/features/chat/application/composer/context-references.ts +++ b/src/features/chat/application/composer/context-references.ts @@ -8,7 +8,7 @@ export interface ComposerContextRange { to: ComposerContextPosition; } -interface ActiveNoteContextReference { +export interface ActiveNoteContextReference { name: string; path: string; linktext: string; @@ -25,6 +25,7 @@ export interface SelectionContextReference { export interface ComposerContextReferences { activeNote: ActiveNoteContextReference | null; selection: SelectionContextReference | null; + activeNoteSnapshots?: readonly ActiveNoteContextReference[]; selectionSnapshots?: readonly SelectionContextReference[]; } @@ -34,7 +35,7 @@ export interface ComposerContextReferenceProvider { } export function emptyComposerContextReferences(): ComposerContextReferences { - return { activeNote: null, selection: null, selectionSnapshots: [] }; + return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] }; } export function formatComposerContextRange(range: ComposerContextRange): string { @@ -45,6 +46,10 @@ export function selectionContextReferenceMarker(selection: SelectionContextRefer return `[[${selection.linktext}]] (${formatComposerContextRange(selection.range)})`; } +export function activeNoteContextReferenceMarker(activeNote: ActiveNoteContextReference): string { + return `[[${activeNote.linktext}]]`; +} + function formatComposerContextPosition(position: ComposerContextPosition): string { return `L${String(position.line + 1)}:C${String(position.ch + 1)}`; } diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index a9eaa428..517c9908 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -5,6 +5,8 @@ import type { Thread } from "../../../../domain/threads/model"; import { threadDisplayTitle } from "../../../../domain/threads/title"; import { shortThreadId } from "../../../../shared/id/thread-id"; import { + type ActiveNoteContextReference, + activeNoteContextReferenceMarker, type ComposerContextReferences, formatComposerContextRange, type SelectionContextReference, @@ -20,6 +22,7 @@ export interface ComposerSuggestion { appendSpaceOnInsert?: boolean; tabCursorOffset?: number; suffixOnInsert?: string; + activeNoteContext?: ActiveNoteContextReference; selectionContext?: SelectionContextReference; } @@ -128,8 +131,9 @@ function activeContextReferenceSuggestions( suggestions.push({ display: "Active note", detail: references.activeNote.path, - replacement: `[[${references.activeNote.linktext}]]`, + replacement: activeNoteContextReferenceMarker(references.activeNote), start, + activeNoteContext: references.activeNote, }); } if (references?.selection && "selection".startsWith(query) && query !== "selection") { diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index 26ecc53d..e7a26414 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -7,6 +7,7 @@ import { type RequestMention, } from "../../../../domain/chat/input"; import { + type ActiveNoteContextReference, type ComposerContextReferences, emptyComposerContextReferences, formatComposerContextRange, @@ -69,9 +70,10 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext( const mentions: RequestMention[] = []; const wikilinkMappings: string[] = []; const seenPaths = new Set(); + const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? []; for (const link of parsedWikiLinks(resolvedText)) { - const mention = resolveMention(link.target); + const mention = activeNoteMentionForLink(link, activeNoteSnapshots) ?? resolveMention(link.target); if (!mention || seenPaths.has(mention.path)) continue; seenPaths.add(mention.path); mentions.push(mention); @@ -105,6 +107,11 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext( }; } +function activeNoteMentionForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): RequestMention | null { + const snapshot = snapshots.find((item) => link.raw.trim() === item.linktext && link.target === item.linktext); + return snapshot ? { name: snapshot.name, path: snapshot.path } : null; +} + function textWithContextReferences( text: string, contextReferences: ComposerContextReferences, diff --git a/src/features/chat/application/conversation/composer-submit-actions.ts b/src/features/chat/application/conversation/composer-submit-actions.ts index 287f27b6..5f93003c 100644 --- a/src/features/chat/application/conversation/composer-submit-actions.ts +++ b/src/features/chat/application/conversation/composer-submit-actions.ts @@ -14,7 +14,7 @@ export interface ComposerSubmitActionsHost { composer: { readonly trimmedDraft: string; setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void; - withPreservedContextReferences(operation: () => Promise): Promise; + withPreservedComposerReferences(operation: () => Promise): Promise; }; slashCommandExecutor: { execute(command: SlashCommandName, args: string): Promise; @@ -56,7 +56,7 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise { const slashCommand = parseSlashCommand(text); if (slashCommand) { if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.ensureConnected())) return; - await host.composer.withPreservedContextReferences(async () => { + await host.composer.withPreservedComposerReferences(async () => { host.composer.setDraft("", { clearSuggestions: true }); const result = await host.slashCommandExecutor.execute(slashCommand.command, slashCommand.args); if (result?.composerDraft !== undefined) { diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 69715b1a..1313a673 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -43,7 +43,7 @@ export interface ConversationTurnActionsContext { prepareInput: (text: string) => { text: string; input: CodexInput }; trimmedDraft: () => string; setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; - withPreservedContextReferences: (operation: () => Promise) => Promise; + withPreservedComposerReferences: (operation: () => Promise) => Promise; }; scroll: { showLatest: () => void; @@ -125,7 +125,7 @@ export function createConversationTurnActions( return composer.trimmedDraft(); }, setDraft: composer.setDraft, - withPreservedContextReferences: composer.withPreservedContextReferences, + withPreservedComposerReferences: composer.withPreservedComposerReferences, }, slashCommandExecutor: { execute: (command, args) => executeSlashCommandWithState(slashCommandExecutorHost, command, args), diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index a047422f..3bad366c 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -678,11 +678,21 @@ function composerSuggestionsEqual(left: readonly ComposerSuggestion[], right: re item.replacement === other.replacement && item.start === other.start && item.appendSpaceOnInsert === other.appendSpaceOnInsert && + composerSuggestionActiveNoteContextEqual(item.activeNoteContext, other.activeNoteContext) && composerSuggestionSelectionContextEqual(item.selectionContext, other.selectionContext) ); }); } +function composerSuggestionActiveNoteContextEqual( + left: ComposerSuggestion["activeNoteContext"], + right: ComposerSuggestion["activeNoteContext"], +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return left.name === right.name && left.path === right.path && left.linktext === right.linktext; +} + function composerSuggestionSelectionContextEqual( left: ComposerSuggestion["selectionContext"], right: ComposerSuggestion["selectionContext"], diff --git a/src/features/chat/host/composer-attachments.obsidian.ts b/src/features/chat/host/composer-attachments.obsidian.ts new file mode 100644 index 00000000..4577e36d --- /dev/null +++ b/src/features/chat/host/composer-attachments.obsidian.ts @@ -0,0 +1,186 @@ +import { type App, normalizePath, type Vault } from "obsidian"; + +import { DEFAULT_ATTACHMENT_FOLDER } from "../../../settings/model"; +import type { ComposerAttachment, ComposerAttachmentHandler } from "../application/composer/attachments"; + +interface VaultComposerAttachmentHandlerOptions { + app: App; + attachmentFolder: () => string; + now?: () => Date; +} + +const GENERATED_ATTACHMENT_PREFIX = "codex-panel"; +const IMAGE_EXTENSIONS = new Set(["avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "webp"]); +const MIME_EXTENSION_BY_TYPE: ReadonlyMap = new Map([ + ["application/pdf", "pdf"], + ["application/zip", "zip"], + ["image/avif", "avif"], + ["image/bmp", "bmp"], + ["image/gif", "gif"], + ["image/heic", "heic"], + ["image/heif", "heif"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/svg+xml", "svg"], + ["image/webp", "webp"], + ["text/csv", "csv"], + ["text/markdown", "md"], + ["text/plain", "txt"], +]); + +export function createVaultComposerAttachmentHandler(options: VaultComposerAttachmentHandlerOptions): ComposerAttachmentHandler { + return { + saveFiles: (files) => saveComposerAttachmentFiles(options, files), + }; +} + +async function saveComposerAttachmentFiles( + options: VaultComposerAttachmentHandlerOptions, + files: readonly File[], +): Promise { + if (files.length === 0) return []; + + const vault = options.app.vault; + const folder = attachmentFolderPath(options.attachmentFolder()); + await ensureFolder(vault, folder); + + const attachments: ComposerAttachment[] = []; + for (const file of files) { + const filename = attachmentFilename(file, options.now?.() ?? new Date()); + const path = await uniqueAttachmentPath(vault, folder, filename); + await vault.createBinary(path, await file.arrayBuffer()); + const kind = isImageFile(file, path) ? "image" : "file"; + attachments.push({ + kind, + name: attachmentDisplayName(path), + path, + marker: kind === "image" ? `![[${path}]]` : `[[${path}]]`, + }); + } + return attachments; +} + +function attachmentFolderPath(value: string): string { + const raw = (value.trim() || DEFAULT_ATTACHMENT_FOLDER).replaceAll("\\", "/"); + if (raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) throw new Error("Attachment folder must be relative to the vault."); + + const rawSegments = raw + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (rawSegments.length === 0) return DEFAULT_ATTACHMENT_FOLDER; + if (rawSegments.some((segment) => segment === "." || segment === "..")) { + throw new Error("Attachment folder cannot contain relative path segments."); + } + + const segments = rawSegments.map((segment) => sanitizePathSegment(segment.trim())).filter(Boolean); + if (segments.length === 0) return DEFAULT_ATTACHMENT_FOLDER; + + const folder = normalizePath(segments.join("/")); + if (!folder) return DEFAULT_ATTACHMENT_FOLDER; + if (folder.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error("Attachment folder cannot contain relative path segments."); + } + return folder; +} + +async function ensureFolder(vault: Vault, folder: string): Promise { + const segments = folder.split("/"); + for (let index = 0; index < segments.length; index += 1) { + const path = segments.slice(0, index + 1).join("/"); + if (!vault.getAbstractFileByPath(path)) await vault.createFolder(path); + } +} + +async function uniqueAttachmentPath(vault: Vault, folder: string, filename: string): Promise { + const { stem, extension } = splitFilename(filename); + let candidate = normalizePath(`${folder}/${filename}`); + let suffix = 1; + while (vault.getAbstractFileByPath(candidate)) { + candidate = normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); + suffix += 1; + } + return candidate; +} + +function attachmentFilename(file: File, now: Date): string { + const originalName = file.name.trim(); + const filename = sanitizeFilename(originalName || generatedAttachmentFilename(file, now)); + return filename || generatedAttachmentFilename(file, now); +} + +function generatedAttachmentFilename(file: File, now: Date): string { + const extension = extensionForMimeType(file.type); + return `${GENERATED_ATTACHMENT_PREFIX}-${formatTimestamp(now)}${extension ? `.${extension}` : ""}`; +} + +function sanitizeFilename(value: string): string { + const normalized = value.replace(/[\\/]+/g, "-").trim(); + const sanitized = sanitizePathSegment(normalized); + if (sanitized && sanitized !== "." && sanitized !== "..") return sanitized; + return ""; +} + +function sanitizePathSegment(value: string): string { + return value + .split("") + .map((char) => (isUnsafePathChar(char) ? "-" : char)) + .join("") + .replace(/\s+/g, " ") + .trim() + .replace(/^\.+$/, "") + .slice(0, 120) + .trim(); +} + +function isUnsafePathChar(char: string): boolean { + return char.charCodeAt(0) < 32 || '<>:"/\\|?*[]#^'.includes(char); +} + +function splitFilename(filename: string): { stem: string; extension: string } { + const dotIndex = filename.lastIndexOf("."); + if (dotIndex <= 0) return { stem: filename, extension: "" }; + return { + stem: filename.slice(0, dotIndex), + extension: filename.slice(dotIndex), + }; +} + +function isImageFile(file: File, path: string): boolean { + const type = file.type.toLowerCase(); + if (type.startsWith("image/")) return true; + const extension = pathExtension(path); + return extension ? IMAGE_EXTENSIONS.has(extension) : false; +} + +function pathExtension(path: string): string | null { + const filename = path.split("/").pop() ?? ""; + const dotIndex = filename.lastIndexOf("."); + return dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : null; +} + +function extensionForMimeType(type: string): string | null { + return MIME_EXTENSION_BY_TYPE.get(type.toLowerCase()) ?? null; +} + +function attachmentDisplayName(path: string): string { + const filename = path.split("/").pop() ?? path; + const { stem } = splitFilename(filename); + return stem || filename; +} + +function formatTimestamp(date: Date): string { + return [ + String(date.getFullYear()), + pad2(date.getMonth() + 1), + pad2(date.getDate()), + "-", + pad2(date.getHours()), + pad2(date.getMinutes()), + pad2(date.getSeconds()), + ].join(""); +} + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} diff --git a/src/features/chat/host/composer-bundle.ts b/src/features/chat/host/composer-bundle.ts index 13ea226e..5b2bafac 100644 --- a/src/features/chat/host/composer-bundle.ts +++ b/src/features/chat/host/composer-bundle.ts @@ -1,3 +1,5 @@ +import { Notice } from "obsidian"; + import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatStateStore } from "../application/state/store"; @@ -5,6 +7,7 @@ import { resolveRuntimeControls } from "../domain/runtime/resolution"; import { ChatComposerController } from "../panel/composer-controller"; import { type ChatPanelComposerSurface, chatPanelComposerProjection } from "../panel/surface/composer-projection"; import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll"; +import { createVaultComposerAttachmentHandler } from "./composer-attachments.obsidian"; import type { ChatPanelEnvironment } from "./contracts"; import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; import type { ChatPanelThreadLifecycle } from "./thread-bundle"; @@ -64,6 +67,10 @@ function createSessionComposerController( return new ChatComposerController({ noteCandidateProvider: new VaultNoteCandidateProvider(environment.obsidian.app), contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app), + attachmentHandler: createVaultComposerAttachmentHandler({ + app: environment.obsidian.app, + attachmentFolder: () => environment.plugin.settingsRef.settings.attachmentFolder(), + }), sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "", stateStore, viewId: environment.obsidian.viewId, @@ -88,5 +95,8 @@ function createSessionComposerController( environment.plugin.workspace.refreshThreadsViewLiveState(); }, onHeightChange: () => undefined, + onAttachmentError: (message) => { + new Notice(message); + }, }); } diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index daee5924..f1da5536 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -25,6 +25,7 @@ interface ChatPanelSettingsRef { } export interface ChatPanelSettingsAccess { + attachmentFolder(): string; archiveExportEnabled(): boolean; archiveExportSettings(): ArchiveExportSettings; codexPath(): string; diff --git a/src/features/chat/host/turn-bundle.ts b/src/features/chat/host/turn-bundle.ts index eeea0cfc..95776f1d 100644 --- a/src/features/chat/host/turn-bundle.ts +++ b/src/features/chat/host/turn-bundle.ts @@ -168,7 +168,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn setDraft: (text, options) => { composerController.setDraft(text, options); }, - withPreservedContextReferences: (operation) => composerController.withPreservedContextReferences(operation), + withPreservedComposerReferences: (operation) => composerController.withPreservedComposerReferences(operation), }, scroll: { showLatest: () => { diff --git a/src/features/chat/panel/composer-controller.dom.ts b/src/features/chat/panel/composer-controller.dom.ts index 6a1d45e8..058855b7 100644 --- a/src/features/chat/panel/composer-controller.dom.ts +++ b/src/features/chat/panel/composer-controller.dom.ts @@ -8,6 +8,12 @@ export interface ComposerElementInsertion { cursor: number; } +export interface ComposerElementRangeInsertion { + value: string; + start: number; + end: number; +} + export function focusComposer(composer: HTMLTextAreaElement | null, options: { preventScroll?: boolean } = {}): void { composer?.focus(options); } @@ -34,6 +40,15 @@ export function composerInsertionSource(composer: HTMLTextAreaElement | null): C }; } +export function composerRangeInsertionSource(composer: HTMLTextAreaElement | null): ComposerElementRangeInsertion | null { + if (!composer) return null; + return { + value: composer.value, + start: composer.selectionStart, + end: composer.selectionEnd, + }; +} + export function composerBoundaryScrollActionFromElement( event: KeyboardEvent, composer: HTMLTextAreaElement | null, @@ -55,3 +70,25 @@ export function applyComposerInsertionToElement(composer: HTMLTextAreaElement | composer.focus(); composer.setSelectionRange(cursor, cursor); } + +export function composerFilesFromTransfer(dataTransfer: DataTransfer | null): File[] { + if (!dataTransfer) return []; + const transfer = dataTransfer as Partial; + const files = Array.from(transfer.files ?? []).filter((file) => file.size > 0); + if (files.length > 0) return files; + + return Array.from(transfer.items ?? []) + .filter((item) => item.kind === "file") + .flatMap((item) => { + const file = item.getAsFile(); + return file && file.size > 0 ? [file] : []; + }); +} + +export function composerTransferHasFiles(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer) return false; + const transfer = dataTransfer as Partial; + if (Array.from(transfer.types ?? []).includes("Files")) return true; + if (Array.from(transfer.items ?? []).some((item) => item.kind === "file")) return true; + return Array.from(transfer.files ?? []).some((file) => file.size > 0); +} diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 0ea48203..fba45073 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -1,7 +1,14 @@ import type { CodexInput } from "../../../domain/chat/input"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; +import { + type ComposerAttachment, + type ComposerAttachmentHandler, + codexInputWithComposerAttachments, +} from "../application/composer/attachments"; import type { ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll"; import { + type ActiveNoteContextReference, + activeNoteContextReferenceMarker, type ComposerContextReferenceProvider, type SelectionContextReference, selectionContextReferenceMarker, @@ -27,10 +34,13 @@ import { syncComposerHeight } from "../ui/composer.dom"; import { applyComposerInsertionToElement, composerBoundaryScrollActionFromElement, + composerFilesFromTransfer, composerHasFocus, composerInsertionSource, + composerRangeInsertionSource, composerSuggestionSignatureFromElement, composerTextBeforeCursor, + composerTransferHasFiles, focusComposer, } from "./composer-controller.dom"; import type { ChatPanelComposerShellState } from "./shell-state"; @@ -39,6 +49,7 @@ import type { ChatPanelComposerProjection } from "./surface/composer-projection" export interface ChatComposerControllerOptions { noteCandidateProvider: NoteCandidateProvider; contextReferenceProvider: ComposerContextReferenceProvider; + attachmentHandler?: ComposerAttachmentHandler; sourcePath: () => string; stateStore: ChatStateStore; viewId: string; @@ -53,6 +64,7 @@ export interface ChatComposerControllerOptions { toggleFast: () => void; onDraftChange: () => void; onHeightChange: () => void; + onAttachmentError?: (message: string) => void; } export interface ChatComposerRenderActions { @@ -61,8 +73,14 @@ export interface ChatComposerRenderActions { export class ChatComposerController { private composer: HTMLTextAreaElement | null = null; + private attachments: ComposerAttachment[] = []; + private pendingAttachmentTransfers = new Set>(); + private submitAfterAttachmentTransfersActive = false; + private activeNoteContextSnapshots: ActiveNoteContextReference[] = []; + private preservedActiveNoteContextSnapshots: readonly ActiveNoteContextReference[] | null = null; private selectionContextSnapshots: SelectionContextReference[] = []; private preservedSelectionContextSnapshots: readonly SelectionContextReference[] | null = null; + private preservedAttachments: readonly ComposerAttachment[] | null = null; constructor(private readonly options: ChatComposerControllerOptions) {} @@ -104,7 +122,9 @@ export class ChatComposerController { }; setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean } = {}): void { + this.pruneActiveNoteContextSnapshots(text); this.pruneSelectionContextSnapshots(text); + this.pruneAttachments(text); this.dispatch({ type: "composer/draft-set", draft: text, @@ -134,30 +154,41 @@ export class ChatComposerController { codexInput(text: string): CodexInput { const sourcePath = this.options.sourcePath(); - return userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkMentionsAndSkills( text, (target) => this.options.noteCandidateProvider.resolveMention(target, sourcePath), this.state.connection.availableSkills, ); + return codexInputWithComposerAttachments(text, input, this.activeAttachments(text)); } preparedInput(text: string): PreparedComposerInput { const sourcePath = this.options.sourcePath(); - return preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( text, (target) => this.options.noteCandidateProvider.resolveMention(target, sourcePath), this.state.connection.availableSkills, this.contextReferences(text), ); + return { + text: prepared.text, + input: codexInputWithComposerAttachments(prepared.text, prepared.input, this.activeAttachments(prepared.text)), + }; } - async withPreservedContextReferences(operation: () => Promise): Promise { - const previous = this.preservedSelectionContextSnapshots; - this.preservedSelectionContextSnapshots = [...this.selectionContextSnapshots]; + async withPreservedComposerReferences(operation: () => Promise): Promise { + const previousActiveNoteSnapshots = this.preservedActiveNoteContextSnapshots; + const previousSelectionSnapshots = this.preservedSelectionContextSnapshots; + const previousAttachments = this.preservedAttachments; + this.preservedActiveNoteContextSnapshots = [...(this.preservedActiveNoteContextSnapshots ?? this.activeNoteContextSnapshots)]; + this.preservedSelectionContextSnapshots = [...(this.preservedSelectionContextSnapshots ?? this.selectionContextSnapshots)]; + this.preservedAttachments = [...(this.preservedAttachments ?? this.attachments)]; try { return await operation(); } finally { - this.preservedSelectionContextSnapshots = previous; + this.preservedActiveNoteContextSnapshots = previousActiveNoteSnapshots; + this.preservedSelectionContextSnapshots = previousSelectionSnapshots; + this.preservedAttachments = previousAttachments; } } @@ -234,7 +265,9 @@ export class ChatComposerController { } private handleInput(value: string): void { + this.pruneActiveNoteContextSnapshots(value); this.pruneSelectionContextSnapshots(value); + this.pruneAttachments(value); const suggestionState = this.inputSuggestionState(); this.dispatch({ type: "composer/input-set", @@ -286,7 +319,9 @@ export class ChatComposerController { if (!source) return; const insertion = applyComposerSuggestionInsertion(source.value, source.cursor, suggestion, { activation }); + if (suggestion.activeNoteContext) this.rememberActiveNoteContextSnapshot(suggestion.activeNoteContext); if (suggestion.selectionContext) this.rememberSelectionContextSnapshot(suggestion.selectionContext); + this.pruneActiveNoteContextSnapshots(insertion.value); this.pruneSelectionContextSnapshots(insertion.value); this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); @@ -298,6 +333,48 @@ export class ChatComposerController { this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [], selected: 0 }); } + private handleTransferredFiles(files: readonly File[]): void { + if (files.length === 0) return; + const handler = this.options.attachmentHandler; + if (!handler) return; + + const transfer = this.saveTransferredFiles(handler, files); + this.pendingAttachmentTransfers.add(transfer); + void transfer.finally(() => { + this.pendingAttachmentTransfers.delete(transfer); + }); + } + + private async saveTransferredFiles(handler: ComposerAttachmentHandler, files: readonly File[]): Promise { + try { + const attachments = await handler.saveFiles(files); + if (attachments.length === 0) return; + this.attachments = [...this.attachments, ...attachments]; + this.insertAttachmentMarkers(attachments); + } catch (error) { + this.options.onAttachmentError?.(error instanceof Error ? error.message : String(error)); + } + } + + private insertAttachmentMarkers(attachments: readonly ComposerAttachment[]): void { + const markers = attachments.map((attachment) => attachment.marker); + if (markers.length === 0) return; + + const fallbackSource = { + value: this.state.composer.draft, + start: this.state.composer.draft.length, + end: this.state.composer.draft.length, + }; + const source = composerRangeInsertionSource(this.composer) ?? fallbackSource; + const insertion = applyAttachmentMarkerInsertion(source.value, source.start, source.end, markers); + this.pruneAttachments(insertion.value); + this.pruneActiveNoteContextSnapshots(insertion.value); + this.pruneSelectionContextSnapshots(insertion.value); + this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); + this.options.onDraftChange(); + applyComposerInsertionToElement(this.composer, insertion.cursor); + } + private dismissSuggestions(): void { this.dispatchSuggestions({ type: "composer/suggestions-set", @@ -321,12 +398,25 @@ export class ChatComposerController { private contextReferences(text: string | null = null) { const references = this.options.contextReferenceProvider.contextReferences(this.options.sourcePath()); - const availableSnapshots = this.preservedSelectionContextSnapshots ?? this.selectionContextSnapshots; + const availableActiveNoteSnapshots = this.preservedActiveNoteContextSnapshots ?? this.activeNoteContextSnapshots; + const availableSelectionSnapshots = this.preservedSelectionContextSnapshots ?? this.selectionContextSnapshots; + const activeNoteSnapshots = + text === null + ? availableActiveNoteSnapshots + : availableActiveNoteSnapshots.filter((activeNote) => text.includes(activeNoteContextReferenceMarker(activeNote))); const selectionSnapshots = text === null - ? availableSnapshots - : availableSnapshots.filter((selection) => text.includes(selectionContextReferenceMarker(selection))); - return { ...references, selectionSnapshots }; + ? availableSelectionSnapshots + : availableSelectionSnapshots.filter((selection) => text.includes(selectionContextReferenceMarker(selection))); + return { ...references, activeNoteSnapshots, selectionSnapshots }; + } + + private rememberActiveNoteContextSnapshot(activeNote: ActiveNoteContextReference): void { + const marker = activeNoteContextReferenceMarker(activeNote); + this.activeNoteContextSnapshots = [ + ...this.activeNoteContextSnapshots.filter((snapshot) => activeNoteContextReferenceMarker(snapshot) !== marker), + activeNote, + ]; } private rememberSelectionContextSnapshot(selection: SelectionContextReference): void { @@ -338,9 +428,35 @@ export class ChatComposerController { } private pruneSelectionContextSnapshots(text: string): void { - this.selectionContextSnapshots = this.selectionContextSnapshots.filter((selection) => - text.includes(selectionContextReferenceMarker(selection)), - ); + const snapshots = this.preservedSelectionContextSnapshots ?? this.selectionContextSnapshots; + this.selectionContextSnapshots = snapshots.filter((selection) => text.includes(selectionContextReferenceMarker(selection))); + } + + private pruneActiveNoteContextSnapshots(text: string): void { + const snapshots = this.preservedActiveNoteContextSnapshots ?? this.activeNoteContextSnapshots; + this.activeNoteContextSnapshots = snapshots.filter((activeNote) => text.includes(activeNoteContextReferenceMarker(activeNote))); + } + + private activeAttachments(text: string): readonly ComposerAttachment[] { + const attachments = this.preservedAttachments ?? this.attachments; + return attachments.filter((attachment) => text.includes(attachment.marker)); + } + + private pruneAttachments(text: string): void { + this.attachments = [...this.activeAttachments(text)]; + } + + private async submitAfterAttachmentTransfers(actions: ChatComposerRenderActions): Promise { + if (this.submitAfterAttachmentTransfersActive) return; + this.submitAfterAttachmentTransfersActive = true; + try { + while (this.pendingAttachmentTransfers.size > 0) { + await Promise.allSettled([...this.pendingAttachmentTransfers]); + } + actions.submit(); + } finally { + this.submitAfterAttachmentTransfersActive = false; + } } private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks { @@ -360,11 +476,28 @@ export class ChatComposerController { } if (isComposerSendKey(event, this.options.sendShortcut())) { event.preventDefault(); - actions.submit(); + void this.submitAfterAttachmentTransfers(actions); } }, + onPaste: (event) => { + const files = composerFilesFromTransfer(event.clipboardData); + if (files.length === 0) return; + event.preventDefault(); + this.handleTransferredFiles(files); + }, + onDrop: (event) => { + const files = composerFilesFromTransfer(event.dataTransfer); + if (files.length === 0) return; + event.preventDefault(); + this.handleTransferredFiles(files); + }, + onDragOver: (event) => { + if (!composerTransferHasFiles(event.dataTransfer)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }, onSendOrInterrupt: () => { - actions.submit(); + void this.submitAfterAttachmentTransfers(actions); }, onHeightChange: () => { this.options.onHeightChange(); @@ -387,3 +520,21 @@ export class ChatComposerController { }; } } + +function applyAttachmentMarkerInsertion( + value: string, + start: number, + end: number, + markers: readonly string[], +): { value: string; cursor: number } { + const prefix = value.slice(0, start); + const suffix = value.slice(end); + const before = prefix && !prefix.endsWith("\n") ? "\n" : ""; + const after = suffix && !suffix.startsWith("\n") ? "\n" : ""; + const inserted = markers.join("\n"); + const nextValue = `${prefix}${before}${inserted}${after}${suffix}`; + return { + value: nextValue, + cursor: prefix.length + before.length + inserted.length, + }; +} diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 54702181..13cb1e07 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -61,6 +61,9 @@ export interface ComposerCallbacks { onInput: (value: string) => void; onUpdateSuggestions: () => void; onKeydown: (event: KeyboardEvent) => void; + onPaste?: (event: ClipboardEvent) => void; + onDrop?: (event: DragEvent) => void; + onDragOver?: (event: DragEvent) => void; onSendOrInterrupt: () => void; onHeightChange: () => void; onTogglePlan?: () => void; @@ -156,6 +159,15 @@ export function ComposerShell({ onKeyDown={(event) => { callbacks.onKeydown(event); }} + onPaste={(event) => { + callbacks.onPaste?.(event); + }} + onDrop={(event) => { + callbacks.onDrop?.(event); + }} + onDragOver={(event) => { + callbacks.onDragOver?.(event); + }} /> diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 37814fa0..dd52687a 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -132,6 +132,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { private chatSettings(): ChatPanelSettingsAccess { return { + attachmentFolder: () => this.options.settingsRef.settings.attachmentFolder, archiveExportEnabled: () => this.options.settingsRef.settings.archiveExportEnabled, archiveExportSettings: () => ({ archiveExportFolderTemplate: this.options.settingsRef.settings.archiveExportFolderTemplate, diff --git a/src/settings/model.ts b/src/settings/model.ts index 250a7f11..03614109 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -14,12 +14,15 @@ export interface CodexPanelSettings { showToolbar: boolean; sendShortcut: SendShortcut; scrollThreadFromComposerEdges: boolean; + attachmentFolder: string; archiveExportEnabled: boolean; archiveExportFolderTemplate: string; archiveExportFilenameTemplate: string; archiveExportTags: string; } +export const DEFAULT_ATTACHMENT_FOLDER = "Codex Attachments"; + export const DEFAULT_SETTINGS: CodexPanelSettings = { codexPath: DEFAULT_CODEX_PATH, threadNamingModel: null, @@ -29,6 +32,7 @@ export const DEFAULT_SETTINGS: CodexPanelSettings = { showToolbar: true, sendShortcut: "enter", scrollThreadFromComposerEdges: false, + attachmentFolder: DEFAULT_ATTACHMENT_FOLDER, archiveExportEnabled: false, archiveExportFolderTemplate: "Codex Archives", archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md", @@ -51,6 +55,8 @@ export function normalizeSettings(storedSettings: unknown): CodexPanelSettings { record["scrollThreadFromComposerEdges"], DEFAULT_SETTINGS.scrollThreadFromComposerEdges, ), + attachmentFolder: + stringOrDefault(record["attachmentFolder"], DEFAULT_SETTINGS.attachmentFolder).trim() || DEFAULT_SETTINGS.attachmentFolder, archiveExportEnabled: booleanOrDefault(record["archiveExportEnabled"], DEFAULT_SETTINGS.archiveExportEnabled), archiveExportFolderTemplate: stringOrDefault( record["archiveExportFolderTemplate"], diff --git a/src/settings/tab-shell.tsx b/src/settings/tab-shell.tsx index 41135859..90ef23d1 100644 --- a/src/settings/tab-shell.tsx +++ b/src/settings/tab-shell.tsx @@ -6,6 +6,7 @@ import type { SendShortcut } from "../shared/ui/keyboard"; import { ArchivedThreadSection } from "./archived-section"; import { HelperSettingsSection } from "./helper-section"; import { HookSection } from "./hook-section"; +import { DEFAULT_ATTACHMENT_FOLDER } from "./model"; import type { SettingsSectionsState } from "./section-state"; import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components"; @@ -23,6 +24,7 @@ interface SettingsTabPanelState { showToolbar: boolean; sendShortcut: SendShortcut; scrollThreadFromComposerEdges: boolean; + attachmentFolder: string; } interface SettingsTabShellActions { @@ -31,6 +33,7 @@ interface SettingsTabShellActions { setShowToolbar: (value: boolean) => void; setSendShortcut: (value: SendShortcut) => void; setScrollThreadFromComposerEdges: (value: boolean) => void; + setAttachmentFolder: (value: string) => void; } interface SettingsTabShellProps { @@ -103,6 +106,13 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt > + + + diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index 6bf7adaf..40296422 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -6,6 +6,7 @@ import { listenDomEvent } from "../shared/ui/dom-events.dom"; import { renderUiRoot, unmountUiRoot } from "../shared/ui/ui-root.dom"; import { SettingsDynamicSectionsController } from "./dynamic-sections-controller"; import type { CodexPanelSettingTabHost } from "./host"; +import { DEFAULT_ATTACHMENT_FOLDER } from "./model"; import type { SettingsSectionsState } from "./section-state"; import { SettingsTabShell } from "./tab-shell"; @@ -79,6 +80,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { showToolbar: this.plugin.settings.showToolbar, sendShortcut: this.plugin.settings.sendShortcut, scrollThreadFromComposerEdges: this.plugin.settings.scrollThreadFromComposerEdges, + attachmentFolder: this.plugin.settings.attachmentFolder, }} sections={this.settingsSectionsState()} actions={{ @@ -97,6 +99,9 @@ export class CodexPanelSettingTab extends PluginSettingTab { setScrollThreadFromComposerEdges: (value) => { void this.setScrollThreadFromComposerEdges(value); }, + setAttachmentFolder: (value) => { + void this.setAttachmentFolder(value); + }, }} />, ); @@ -200,6 +205,12 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.renderSettingsShell(); } + private async setAttachmentFolder(value: string): Promise { + this.plugin.settings.attachmentFolder = value.trim() || DEFAULT_ATTACHMENT_FOLDER; + await this.plugin.saveSettings(); + this.renderSettingsShell(); + } + private async setArchiveExportEnabled(enabled: boolean): Promise { this.plugin.settings.archiveExportEnabled = enabled; await this.plugin.saveSettings(); diff --git a/tests/app-server/turn.test.ts b/tests/app-server/turn.test.ts index d91ec83a..7362c768 100644 --- a/tests/app-server/turn.test.ts +++ b/tests/app-server/turn.test.ts @@ -81,6 +81,49 @@ describe("app-server turn records", () => { ]); }); + it("keeps local image attachments out of user transcript text when text is present", () => { + expect( + transcriptEntriesFromTurnRecords([ + turn([ + { + type: "userMessage", + id: "u1", + clientId: null, + content: [ + { type: "text", text: "![[Codex Attachments/diagram.png]]", text_elements: [] }, + { type: "localImage", path: "Codex Attachments/diagram.png" }, + ], + }, + ]), + ]), + ).toEqual([{ kind: "user", text: "![[Codex Attachments/diagram.png]]", timestamp: null }]); + }); + + it("keeps image attachment stubs when user text does not contain the image reference", () => { + expect( + transcriptEntriesFromTurnRecords([ + turn([ + { + type: "userMessage", + id: "u1", + clientId: null, + content: [ + { type: "text", text: "この画像を見てください", text_elements: [] }, + { type: "localImage", path: "Codex Attachments/diagram.png" }, + { type: "image", url: "https://example.com/diagram.png" }, + ], + }, + ]), + ]), + ).toEqual([ + { + kind: "user", + text: "この画像を見てください\n[local image] Codex Attachments/diagram.png\n[image] https://example.com/diagram.png", + timestamp: null, + }, + ]); + }); + it("extracts assistant-like conversation text for generated turn consumers", () => { expect(conversationAssistantTextFromTurnRecord(turn([userMessage("u1", "依頼"), planItem("p1", "計画")]))).toBe("計画"); }); diff --git a/tests/features/chat/application/composer/wikilink-context.test.ts b/tests/features/chat/application/composer/wikilink-context.test.ts index 46fb3500..e2b97142 100644 --- a/tests/features/chat/application/composer/wikilink-context.test.ts +++ b/tests/features/chat/application/composer/wikilink-context.test.ts @@ -173,6 +173,16 @@ describe("wikilink context", () => { expect(prepared.input).toEqual([{ type: "text", text }]); }); + it("resolves completed active-note snapshots without depending on the current link context", () => { + const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext("整理して [[Alpha]]", () => null, [], { + activeNote: null, + selection: null, + activeNoteSnapshots: [{ name: "Alpha", path: "notes/Alpha.md", linktext: "Alpha" }], + }); + + expect(prepared.input).toContainEqual({ type: "mention", name: "Alpha", path: "notes/Alpha.md" }); + }); + it("attaches completed selection snapshots without depending on the current editor selection", () => { const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( "整理して [[notes/Alpha]] (L42:C5-L47:C1)", diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/conversation/composer-submit-actions.test.ts index fc148398..63568a07 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/conversation/composer-submit-actions.test.ts @@ -31,7 +31,7 @@ function createHost(draft: string) { return draft; }, setDraft, - withPreservedContextReferences: (operation: () => Promise) => operation(), + withPreservedComposerReferences: (operation: () => Promise) => operation(), }, slashCommandExecutor: { execute }, turnSubmission: { sendTurnText }, @@ -78,10 +78,10 @@ describe("submitComposer", () => { expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined); }); - it("preserves composer context references until slash command send results are submitted", async () => { + it("preserves composer references until slash command send results are submitted", async () => { const { host, execute, sendTurnText } = createHost("/refer Other [[Note]] (L1:C1-L1:C2)"); let preserving = false; - host.composer.withPreservedContextReferences = async (operation) => { + host.composer.withPreservedComposerReferences = async (operation) => { preserving = true; try { return await operation(); diff --git a/tests/features/chat/host/composer-attachments.test.ts b/tests/features/chat/host/composer-attachments.test.ts new file mode 100644 index 00000000..ea8cbfbb --- /dev/null +++ b/tests/features/chat/host/composer-attachments.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { createVaultComposerAttachmentHandler } from "../../../../src/features/chat/host/composer-attachments.obsidian"; + +describe("vault composer attachments", () => { + it("saves unnamed pasted images with a generated filename and Obsidian embed marker", async () => { + const vault = vaultFixture(); + const handler = createVaultComposerAttachmentHandler({ + app: { vault } as never, + attachmentFolder: () => "Codex Attachments", + now: () => new Date("2026-06-28T15:30:12"), + }); + + const attachments = await handler.saveFiles([new File(["image"], "", { type: "image/png" })]); + + expect(vault.createFolder).toHaveBeenCalledWith("Codex Attachments"); + expect(vault.createBinary).toHaveBeenCalledWith("Codex Attachments/codex-panel-20260628-153012.png", expect.any(ArrayBuffer)); + expect(attachments).toEqual([ + { + kind: "image", + name: "codex-panel-20260628-153012", + path: "Codex Attachments/codex-panel-20260628-153012.png", + marker: "![[Codex Attachments/codex-panel-20260628-153012.png]]", + }, + ]); + }); + + it("keeps known filenames and appends a numeric suffix when the target exists", async () => { + const vault = vaultFixture(["Files/Paper.pdf", "Files/Paper 1.pdf"]); + const handler = createVaultComposerAttachmentHandler({ + app: { vault } as never, + attachmentFolder: () => "Files", + }); + + const attachments = await handler.saveFiles([new File(["pdf"], "Paper.pdf", { type: "application/pdf" })]); + + expect(vault.createBinary).toHaveBeenCalledWith("Files/Paper 2.pdf", expect.any(ArrayBuffer)); + expect(attachments).toEqual([ + { + kind: "file", + name: "Paper 2", + path: "Files/Paper 2.pdf", + marker: "[[Files/Paper 2.pdf]]", + }, + ]); + }); + + it("rejects relative attachment folder segments before sanitizing", async () => { + const handler = createVaultComposerAttachmentHandler({ + app: { vault: vaultFixture() } as never, + attachmentFolder: () => "../Files", + }); + + await expect(handler.saveFiles([new File(["pdf"], "Paper.pdf", { type: "application/pdf" })])).rejects.toThrow( + "Attachment folder cannot contain relative path segments.", + ); + }); +}); + +function vaultFixture(existingPaths: readonly string[] = []) { + const existing = new Set(existingPaths); + return { + getAbstractFileByPath: vi.fn((path: string) => (existing.has(path) ? {} : null)), + createFolder: vi.fn(async (path: string) => { + existing.add(path); + }), + createBinary: vi.fn(async (path: string) => { + existing.add(path); + }), + }; +} diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index 27e8d682..e5bdc6fc 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -92,7 +92,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType operation()), + withPreservedComposerReferences: vi.fn((operation) => operation()), hasFocus: vi.fn(() => false), focusComposer: vi.fn(), }, diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 29a41374..05d57f11 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -3,7 +3,11 @@ import { h } from "preact"; import { describe, expect, it, vi } from "vitest"; import type { SkillMetadata } from "../../../../src/domain/catalog/metadata"; -import type { ComposerContextReferenceProvider } from "../../../../src/features/chat/application/composer/context-references"; +import type { ComposerAttachment, ComposerAttachmentHandler } from "../../../../src/features/chat/application/composer/attachments"; +import type { + ComposerContextReferenceProvider, + ComposerContextReferences, +} from "../../../../src/features/chat/application/composer/context-references"; import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context"; import type { ChatStateStore } from "../../../../src/features/chat/application/state/store"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; @@ -98,6 +102,318 @@ describe("ChatComposerController", () => { expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/"); }); + it("saves pasted images, inserts an Obsidian embed, and sends a local image attachment", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const attachmentHandler: ComposerAttachmentHandler = { + saveFiles: vi.fn().mockResolvedValue([ + { + kind: "image", + name: "diagram", + path: "Codex Attachments/diagram.png", + marker: "![[Codex Attachments/diagram.png]]", + }, + ]), + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + attachmentHandler, + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })])); + await flushComposerAttachment(); + + expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce(); + expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]"); + expect(controller.preparedInput(composer(parent).value).input).toEqual([ + { type: "text", text: "![[Codex Attachments/diagram.png]]" }, + { type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" }, + { type: "localImage", path: "Codex Attachments/diagram.png" }, + ]); + }); + + it("preserves pasted image attachments while slash commands clear the draft", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const attachmentHandler: ComposerAttachmentHandler = { + saveFiles: vi.fn().mockResolvedValue([ + { + kind: "image", + name: "diagram", + path: "Codex Attachments/diagram.png", + marker: "![[Codex Attachments/diagram.png]]", + }, + ]), + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + attachmentHandler, + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })])); + await flushComposerAttachment(); + const marker = composer(parent).value; + + await controller.withPreservedComposerReferences(async () => { + controller.setDraft("", { clearSuggestions: true }); + expect(controller.preparedInput(`Inspect ${marker}`).input).toEqual([ + { type: "text", text: `Inspect ${marker}` }, + { type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" }, + { type: "localImage", path: "Codex Attachments/diagram.png" }, + ]); + }); + }); + + it("accepts protected file dragovers before dropped files are readable", () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + const dataTransfer = { + files: [], + items: [{ kind: "file", getAsFile: vi.fn(() => null) }], + types: ["Files"], + dropEffect: "none", + }; + const event = new Event("dragover", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + + renderComposerController(parent, controller, stateStore); + composer(parent).dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(dataTransfer.dropEffect).toBe("copy"); + }); + + it("waits for pending attachment saves before submitting", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const attachment: ComposerAttachment = { + kind: "image", + name: "diagram", + path: "Codex Attachments/diagram.png", + marker: "![[Codex Attachments/diagram.png]]", + }; + const saveResolver: { current?: (attachments: ComposerAttachment[]) => void } = {}; + const attachmentHandler: ComposerAttachmentHandler = { + saveFiles: vi.fn( + () => + new Promise((resolve) => { + saveResolver.current = resolve; + }), + ), + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore, { submit }); + }); + const submit = vi.fn(); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + attachmentHandler, + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + 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" })); + + expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + + if (!saveResolver.current) throw new Error("Expected save resolver."); + saveResolver.current([attachment]); + await flushComposerAttachment(); + await flushComposerAttachment(); + + expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]"); + expect(submit).toHaveBeenCalledOnce(); + }); + + it("saves dropped non-image files, inserts a wikilink, and sends a file mention", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const attachmentHandler: ComposerAttachmentHandler = { + saveFiles: vi.fn().mockResolvedValue([ + { + kind: "file", + name: "paper", + path: "Codex Attachments/paper.pdf", + marker: "[[Codex Attachments/paper.pdf]]", + }, + ]), + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + attachmentHandler, + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + composer(parent).dispatchEvent(transferEvent("drop", "dataTransfer", [new File(["pdf"], "paper.pdf", { type: "application/pdf" })])); + await flushComposerAttachment(); + + expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce(); + expect(composer(parent).value).toBe("[[Codex Attachments/paper.pdf]]"); + expect(controller.preparedInput(composer(parent).value).input).toEqual([ + { type: "text", text: "[[Codex Attachments/paper.pdf]]" }, + { type: "mention", name: "paper", path: "Codex Attachments/paper.pdf" }, + ]); + }); + + it("freezes active note context when inserting the active-note suggestion", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + let references: ComposerContextReferences = { + activeNote: { name: "Alpha", path: "notes/Alpha.md", linktext: "Alpha" }, + selection: null, + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider({ resolveMention: () => null }), + contextReferenceProvider: contextProvider(() => references), + sourcePath: () => "Inbox.md", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + setTextAreaValue(composer(parent), "@active"); + composer(parent).setSelectionRange(7, 7); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + references = { activeNote: null, selection: null }; + const completedActiveNoteReference = composer(parent).value; + + expect(completedActiveNoteReference).toBe("[[Alpha]]"); + expect(controller.preparedInput(completedActiveNoteReference).input).toContainEqual({ + type: "mention", + name: "Alpha", + path: "notes/Alpha.md", + }); + + await controller.withPreservedComposerReferences(async () => { + controller.setDraft("", { clearSuggestions: true }); + expect(controller.preparedInput(completedActiveNoteReference).input).toContainEqual({ + type: "mention", + name: "Alpha", + path: "notes/Alpha.md", + }); + }); + }); + it("freezes selection context when inserting the selection suggestion", async () => { const stateStore = createChatStateStore(); const parent = document.createElement("div"); @@ -166,7 +482,7 @@ describe("ChatComposerController", () => { "Referenced Obsidian selections for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection", }); - await controller.withPreservedContextReferences(async () => { + await controller.withPreservedComposerReferences(async () => { controller.setDraft("", { clearSuggestions: true }); expect(controller.preparedInput(completedSelectionReference).input).toContainEqual({ type: "additionalContext", @@ -536,6 +852,19 @@ function setTextAreaValue(textarea: HTMLTextAreaElement, value: string): void { descriptor.set.call(textarea, value); } +function transferEvent(type: "paste" | "drop", key: "clipboardData" | "dataTransfer", files: readonly File[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, key, { + value: { files }, + }); + return event; +} + +async function flushComposerAttachment(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + function expectPresent(value: T | null | undefined): T { expect(value).not.toBeNull(); expect(value).not.toBeUndefined(); diff --git a/tests/features/chat/support/settings.ts b/tests/features/chat/support/settings.ts index bc00d2a0..ff47038c 100644 --- a/tests/features/chat/support/settings.ts +++ b/tests/features/chat/support/settings.ts @@ -3,6 +3,7 @@ import type { CodexPanelSettings } from "../../../../src/settings/model"; export function chatPanelSettingsAccess(settings: CodexPanelSettings): ChatPanelSettingsAccess { return { + attachmentFolder: () => settings.attachmentFolder, archiveExportEnabled: () => settings.archiveExportEnabled, archiveExportSettings: () => ({ archiveExportFolderTemplate: settings.archiveExportFolderTemplate, diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index a347894b..43d4121d 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -66,6 +66,7 @@ describe("settings tab", () => { "Composer", "Send shortcut", "Scroll thread from composer line edges", + "Attachment folder", "Codex helpers", "Automatic thread naming", "Selection rewrite", @@ -136,6 +137,23 @@ describe("settings tab", () => { expect(settingDesc(tab, "Scroll thread from composer line edges")).toContain("Up/Ctrl+P"); }); + it("saves the attachment folder setting", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const tab = newSettingsTab({ saveSettings }); + + tab.display(); + const folder = inputForSetting(tab, "Attachment folder"); + if (!folder) throw new Error("Missing attachment folder input"); + expect(folder.type).toBe("text"); + + folder.value = "Files/Codex"; + folder.dispatchEvent(new Event("blur")); + await flushPromises(); + + expect(saveSettings).toHaveBeenCalledOnce(); + expect(settingDesc(tab, "Attachment folder")).toContain("pasted or dropped"); + }); + it("saves archive export settings", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const tab = newSettingsTab({ saveSettings }); @@ -1041,6 +1059,7 @@ function settingsTabHost( showToolbar: true, sendShortcut: options.sendShortcut ?? "enter", scrollThreadFromComposerEdges: false, + attachmentFolder: "Codex Attachments", archiveExportEnabled: false, archiveExportFolderTemplate: "Codex Archives", archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md", diff --git a/tests/settings/settings.test.ts b/tests/settings/settings.test.ts index 87aee189..2f964235 100644 --- a/tests/settings/settings.test.ts +++ b/tests/settings/settings.test.ts @@ -24,6 +24,7 @@ describe("settings", () => { showToolbar: false, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, + attachmentFolder: "Codex Uploads", archiveExportEnabled: true, archiveExportFolderTemplate: "Codex Archives/{{date}}", archiveExportFilenameTemplate: "{{title}} {{shortId}}.md", @@ -42,6 +43,7 @@ describe("settings", () => { showToolbar: false, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, + attachmentFolder: "Codex Uploads", archiveExportEnabled: true, archiveExportFolderTemplate: "Codex Archives/{{date}}", archiveExportFilenameTemplate: "{{title}} {{shortId}}.md", @@ -66,6 +68,7 @@ describe("settings", () => { showToolbar: true, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, + attachmentFolder: "Codex Attachments", archiveExportEnabled: true, archiveExportFolderTemplate: "Codex Archives", archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md", @@ -117,6 +120,12 @@ describe("settings", () => { ); }); + it("normalizes the attachment folder", () => { + expect(normalizeSettings({ attachmentFolder: " Files/Codex " }).attachmentFolder).toBe("Files/Codex"); + expect(normalizeSettings({ attachmentFolder: " " }).attachmentFolder).toBe(DEFAULT_SETTINGS.attachmentFolder); + expect(normalizeSettings({ attachmentFolder: 1 }).attachmentFolder).toBe(DEFAULT_SETTINGS.attachmentFolder); + }); + it("normalizes archive export settings", () => { expect( normalizeSettings({