diff --git a/desktop/app.go b/desktop/app.go index 0cf7808c3..b66867a34 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -3742,38 +3742,59 @@ func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, e return entry, nil } -// SavePastedImage stores a browser clipboard image data URL under the active -// tab's workspace .reasonix/attachments and returns the relative @-reference path. -func (a *App) SavePastedImage(dataURL string) (string, error) { +func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) { + var result string + err := a.withActiveWorkspaceDo(func() error { + var err error + result, err = fn() + return err + }) + return result, err +} + +func (a *App) withActiveWorkspaceDo(fn func() error) error { root := a.activeWorkspaceRoot() if root != "" && root != "." { - if prev, err := os.Getwd(); err == nil { - if err := os.Chdir(root); err == nil { - defer func() { _ = os.Chdir(prev) }() - } + prev, err := os.Getwd() + if err != nil { + return err + } + if err := os.Chdir(root); err != nil { + return err } + defer func() { _ = os.Chdir(prev) }() } - return control.SaveImageDataURL(dataURL) + return fn() +} + +// SavePastedImage stores a browser clipboard image data URL under the active +// tab's workspace .reasonix/attachments and returns the relative @-reference path. +func (a *App) SavePastedImage(dataURL string) (string, error) { + return a.withActiveWorkspace(func() (string, error) { + return control.SaveImageDataURL(dataURL) + }) +} + +// SaveClipboardImage reads the native OS clipboard image under the active tab's +// workspace .reasonix/attachments and returns the relative @-reference path. +func (a *App) SaveClipboardImage() (string, error) { + return a.withActiveWorkspace(control.SaveClipboardImage) } // SavePastedFile stores a dropped non-image file (the browser exposes its bytes // as a data URL but not a real path) under the active tab's workspace // .reasonix/attachments and returns the relative @-reference path. func (a *App) SavePastedFile(name, dataURL string) (string, error) { - root := a.activeWorkspaceRoot() - if root != "" && root != "." { - if prev, err := os.Getwd(); err == nil { - if err := os.Chdir(root); err == nil { - defer func() { _ = os.Chdir(prev) }() - } - } - } - return control.SaveAttachmentDataURL(name, dataURL) + return a.withActiveWorkspace(func() (string, error) { + return control.SaveAttachmentDataURL(name, dataURL) + }) } // AttachmentDataURL returns a safe data URL for a stored image attachment. func (a *App) AttachmentDataURL(path string) (string, error) { - return control.ImageDataURL(path) + return a.withActiveWorkspace(func() (string, error) { + return control.ImageDataURL(path) + }) } // DroppedItem is one OS-dropped file resolved into a composer context entry: an @@ -3791,27 +3812,37 @@ type DroppedItem struct { // thumbnail; other in-workspace files are referenced relatively (no copy); files // outside the workspace are copied into .reasonix/attachments. func (a *App) AttachDropped(path string) (DroppedItem, error) { - info, err := os.Lstat(path) - if err != nil { - return DroppedItem{}, err - } - if isImageExt(path) { - if rel, err := control.SaveImageFile(path); err == nil { - preview, _ := control.ImageDataURL(rel) - return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil + var item DroppedItem + err := a.withActiveWorkspaceDo(func() error { + info, err := os.Lstat(path) + if err != nil { + return err } - } - if rel, ok := workspaceRelative(path); ok { - return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil - } - if info.IsDir() { - return DroppedItem{}, fmt.Errorf("can only attach files from outside the workspace") - } - rel, err := control.SaveAttachmentFile(path) + if isImageExt(path) { + if rel, err := control.SaveImageFile(path); err == nil { + preview, _ := control.ImageDataURL(rel) + item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview} + return nil + } + } + if rel, ok := workspaceRelative(path); ok { + item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()} + return nil + } + if info.IsDir() { + return fmt.Errorf("can only attach files from outside the workspace") + } + rel, err := control.SaveAttachmentFile(path) + if err != nil { + return err + } + item = DroppedItem{Kind: "attachment", Path: rel} + return nil + }) if err != nil { return DroppedItem{}, err } - return DroppedItem{Kind: "attachment", Path: rel}, nil + return item, nil } func isImageExt(path string) bool { diff --git a/desktop/attach_dropped_test.go b/desktop/attach_dropped_test.go index ed6526fe1..d20348ff1 100644 --- a/desktop/attach_dropped_test.go +++ b/desktop/attach_dropped_test.go @@ -1,12 +1,15 @@ package main import ( + "encoding/base64" "os" "path/filepath" "strings" "testing" ) +const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + func TestWorkspaceRelative(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) @@ -38,6 +41,123 @@ func TestIsImageExt(t *testing.T) { } } +func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) { + orig, _ := os.Getwd() + defer os.Chdir(orig) + + launchRoot := t.TempDir() + projectRoot := t.TempDir() + if err := os.Chdir(projectRoot); err != nil { + t.Fatal(err) + } + projectRoot, _ = os.Getwd() + if err := os.Chdir(launchRoot); err != nil { + t.Fatal(err) + } + app := &App{ + tabs: map[string]*WorkspaceTab{ + "project": {ID: "project", WorkspaceRoot: projectRoot}, + }, + activeTabID: "project", + } + + got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG) + if err != nil { + t.Fatalf("SavePastedImage: %v", err) + } + if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil { + t.Fatalf("pasted image should be saved under active workspace: %v", err) + } + if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) { + t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err) + } + preview, err := app.AttachmentDataURL(got) + if err != nil { + t.Fatalf("AttachmentDataURL: %v", err) + } + if !strings.HasPrefix(preview, "data:image/png;base64,") { + t.Fatalf("preview = %q, want png data URL", preview) + } +} + +func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) { + orig, _ := os.Getwd() + defer os.Chdir(orig) + + launchRoot := t.TempDir() + projectRoot := t.TempDir() + if err := os.Chdir(projectRoot); err != nil { + t.Fatal(err) + } + projectRoot, _ = os.Getwd() + if err := os.Chdir(launchRoot); err != nil { + t.Fatal(err) + } + app := &App{ + tabs: map[string]*WorkspaceTab{ + "project": {ID: "project", WorkspaceRoot: projectRoot}, + }, + activeTabID: "project", + } + if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(projectRoot, "sub", "notes.txt") + if err := os.WriteFile(target, []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := app.AttachDropped(target) + if err != nil { + t.Fatalf("AttachDropped: %v", err) + } + if got.Kind != "workspace" || got.Path != "sub/notes.txt" { + t.Fatalf("got %+v, want workspace ref sub/notes.txt", got) + } +} + +func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) { + orig, _ := os.Getwd() + defer os.Chdir(orig) + + launchRoot := t.TempDir() + projectRoot := t.TempDir() + if err := os.Chdir(launchRoot); err != nil { + t.Fatal(err) + } + app := &App{ + tabs: map[string]*WorkspaceTab{ + "project": {ID: "project", WorkspaceRoot: projectRoot}, + }, + activeTabID: "project", + } + raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG) + if err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "shot.png") + if err := os.WriteFile(outside, raw, 0o644); err != nil { + t.Fatal(err) + } + + got, err := app.AttachDropped(outside) + if err != nil { + t.Fatalf("AttachDropped: %v", err) + } + if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") { + t.Fatalf("got %+v, want png attachment", got) + } + if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil { + t.Fatalf("dropped image should be saved under active workspace: %v", err) + } + if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) { + t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err) + } + if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") { + t.Fatalf("preview = %q, want png data URL", got.PreviewURL) + } +} + func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 24c28deca..d578ade5c 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -6,6 +6,7 @@ import { DedupIndex, sha256 } from "../lib/attachDedup"; import { app, onFilesDropped } from "../lib/bridge"; import { SPINNER_WORDS, useI18n } from "../lib/i18n"; import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences"; +import { useToast } from "../lib/toast"; import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type DirEntry, type EffortInfo, type HistoryMessage, type Mode, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode } from "../lib/types"; import { formatWorkspaceReference, @@ -78,6 +79,46 @@ function workspaceReferenceKey(ref: WorkspaceReference): string { return `${ref.isDir ? "dir" : "file"}:${ref.path}`; } +function fileKey(file: File): string { + return `${file.name}:${file.type}:${file.size}:${file.lastModified}`; +} + +function clipboardFiles(data: DataTransfer): File[] { + const files = Array.from(data.files); + const seen = new Set(files.map(fileKey)); + for (const item of Array.from(data.items)) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (!file) continue; + const key = fileKey(file); + if (seen.has(key)) continue; + seen.add(key); + files.push(file); + } + return files; +} + +function clipboardHasImageHint(data: DataTransfer): boolean { + const imageType = (value: string) => { + const type = value.toLowerCase(); + return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff"); + }; + return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType); +} + +function isPasteShortcut(e: KeyboardEvent): boolean { + return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey; +} + +async function dataURLHash(dataUrl: string): Promise { + try { + const res = await fetch(dataUrl); + return sha256(await res.blob()); + } catch { + return ""; + } +} + function composerMaxHeight(): number { if (typeof window === "undefined") return COMPOSER_MAX_HEIGHT; return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO))); @@ -308,6 +349,7 @@ export function Composer({ transientDismissSignal?: number; }) { const { t, locale } = useI18n(); + const { showToast } = useToast(); const now = useTick(running); const [text, setText] = useState(""); const [attachments, setAttachments] = useState([]); @@ -344,6 +386,7 @@ export function Composer({ const consumedInsertIdRef = useRef(0); const lastTransientDismissSignal = useRef(transientDismissSignal); const submittingRef = useRef(false); + const nativeClipboardPasteTimerRef = useRef(null); // Snapshot of the current cwd so async callbacks (openPastChats) can detect // workspace switches and discard stale responses (issue #3601). const cwdRef = useRef(cwd); @@ -351,6 +394,14 @@ export function Composer({ const attachmentDedupRef = useRef(new DedupIndex()); const attachmentDedupKeysRef = useRef>({}); + const clearNativeClipboardPasteTimer = () => { + if (nativeClipboardPasteTimerRef.current === null) return; + window.clearTimeout(nativeClipboardPasteTimerRef.current); + nativeClipboardPasteTimerRef.current = null; + }; + + useEffect(() => () => clearNativeClipboardPasteTimer(), []); + useEffect(() => { if (wasRunning.current && !running && text.trim() === "") { pastedBlocksRef.current = []; @@ -765,7 +816,9 @@ export function Composer({ const previewUrl = await app.AttachmentDataURL(path); rememberAttachment(path, key); setAttachments((prev) => [...prev, { path, previewUrl }]); - } catch { + } catch (error) { + console.warn("[composer] failed to attach pasted image", error); + showToast(t("composer.attachImageFailed"), "warn"); // non-fatal: a failed image attach must not block normal text input } finally { setPendingPaste((n) => Math.max(0, n - 1)); @@ -800,6 +853,23 @@ export function Composer({ void attachOtherFiles(files); }; + const attachNativeClipboardImage = async (notifyOnError: boolean) => { + setPendingPaste((n) => n + 1); + try { + const path = await app.SaveClipboardImage(); + const previewUrl = await app.AttachmentDataURL(path); + const key = { hash: await dataURLHash(previewUrl), source: `native-clipboard:${path}` }; + if (attachmentDedupRef.current.seen(key.hash, key.source)) return; + rememberAttachment(path, key); + setAttachments((prev) => [...prev, { path, previewUrl }]); + } catch (error) { + console.warn("[composer] failed to read native clipboard image", error); + if (notifyOnError) showToast(t("composer.pasteImageFailed"), "warn"); + } finally { + setPendingPaste((n) => Math.max(0, n - 1)); + } + }; + // OS file drops arrive as absolute paths through the native bridge (the webview // withholds them from the HTML drop event); the kernel resolves each into a // workspace @reference or a stored attachment. @@ -830,7 +900,8 @@ export function Composer({ }, []); const onPaste = (e: ClipboardEvent) => { - const files = Array.from(e.clipboardData.files); + clearNativeClipboardPasteTimer(); + const files = clipboardFiles(e.clipboardData); if (files.length > 0) { e.preventDefault(); attachFiles(files); @@ -838,6 +909,12 @@ export function Composer({ } const pasted = e.clipboardData.getData("text"); + const hasImageHint = clipboardHasImageHint(e.clipboardData); + if (hasImageHint || pasted === "") { + e.preventDefault(); + void attachNativeClipboardImage(hasImageHint); + return; + } if (!shouldFoldPaste(pasted)) return; e.preventDefault(); @@ -1215,6 +1292,14 @@ export function Composer({ const composing = isImeKeyEvent(e, composingRef.current, lastCompositionEndAt.current); if (e.key === "Enter" && composing) return; + if (isPasteShortcut(e) && !composing) { + clearNativeClipboardPasteTimer(); + nativeClipboardPasteTimerRef.current = window.setTimeout(() => { + nativeClipboardPasteTimerRef.current = null; + void attachNativeClipboardImage(false); + }, 160); + } + // Shift+Tab toggles plan mode only. Tool access is deliberately changed via // the access menu so keyboard cycling never crosses a permission boundary. if (e.key === "Tab" && e.shiftKey && !composing) { diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index b37415a2c..18eedb179 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -171,6 +171,7 @@ export interface AppBindings { RevealWorkspacePath(rel: string): Promise; RevealPath(path: string): Promise; SavePastedImage(dataUrl: string): Promise; + SaveClipboardImage(): Promise; SavePastedFile(name: string, dataUrl: string): Promise; AttachDropped(path: string): Promise; AttachmentDataURL(path: string): Promise; @@ -1771,6 +1772,9 @@ function makeMockApp(): AppBindings { async SavePastedImage(_dataUrl: string) { return ".reasonix/attachments/mock.png"; }, + async SaveClipboardImage() { + return ".reasonix/attachments/mock-clipboard.png"; + }, async SavePastedFile(name: string, _dataUrl: string) { return `.reasonix/attachments/mock-${name}`; }, diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 682fa0298..cc079648b 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -306,6 +306,8 @@ export const en = { "composer.pastedExpand": "Expand", "composer.pastedRemove": "Remove pasted text", "composer.removeImage": "Remove image", + "composer.attachImageFailed": "Image paste failed", + "composer.pasteImageFailed": "Could not read clipboard image", "composer.contextItems": "Context items", "composer.workspaceReferences": "Workspace references", "composer.removeReference": "Remove reference", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 3ca102db9..e8ae43880 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -307,6 +307,8 @@ export const zh: Record = { "composer.pastedExpand": "展开", "composer.pastedRemove": "删除粘贴文本", "composer.removeImage": "移除图片", + "composer.attachImageFailed": "图片粘贴失败", + "composer.pasteImageFailed": "未能读取剪贴板图片", "composer.contextItems": "上下文项目", "composer.workspaceReferences": "工作区引用", "composer.removeReference": "移除引用",