diff --git a/desktop/src/apps/FilesApp.tsx b/desktop/src/apps/FilesApp.tsx index 0482cc71..4dac9baf 100644 --- a/desktop/src/apps/FilesApp.tsx +++ b/desktop/src/apps/FilesApp.tsx @@ -28,6 +28,7 @@ import { Button, Card, Toolbar, ToolbarGroup, ToolbarSpacer } from "@/components import { MobileSplitView } from "@/components/mobile/MobileSplitView"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { resolveAgentEmoji } from "@/lib/agent-emoji"; +import { useDragSource } from "@/shell/dnd/use-drag-source"; /* ------------------------------------------------------------------ */ /* Types */ @@ -227,6 +228,138 @@ async function apiFetch(url: string, opts?: RequestInit): Promise { return res.json(); } +/* ------------------------------------------------------------------ */ +/* FileRow — list-view row with drag source */ +/* ------------------------------------------------------------------ */ + +interface FileRowProps { + f: FileEntry; + location: "workspace" | string; + currentPath: string; + navigateTo: (path: string) => void; + isWritable: boolean; + deleteConfirm: string | null; + handleDelete: (path: string) => void; + setDeleteConfirm: (path: string | null) => void; +} + +function FileRow({ + f, + location, + currentPath, + navigateTo, + isWritable, + deleteConfirm, + handleDelete, + setDeleteConfirm, +}: FileRowProps) { + const Icon = getFileIcon(f.name, f.is_dir); + const relPath = f.path || (currentPath ? `${currentPath}/${f.name}` : f.name); + + let vfsPath: string | null = null; + if (location === "workspace") { + vfsPath = `/workspaces/user/${relPath}`; + } else if (isAgentLocation(location)) { + vfsPath = `/workspaces/agent/${agentSlug(location)}/${relPath}`; + } + + const dragEnabled = !!vfsPath && !f.is_dir; + const { dragHandlers } = useDragSource({ + // When dragEnabled is false, `disabled: true` short-circuits the payload + // before it ever lands on the bus — the empty-string placeholder below + // is never read. + payload: { + kind: "file", + path: vfsPath ?? "", + mime_type: "application/octet-stream", + size: f.size ?? 0, + name: f.name, + }, + disabled: !dragEnabled, + htmlMirror: dragEnabled && vfsPath ? { "text/plain": vfsPath } : undefined, + }); + + return ( + + + + + + {f.is_dir ? "—" : formatSize(f.size)} + + + {formatDate(f.modified)} + + +
+ {!f.is_dir && isWritable && ( + + + + )} + {isWritable && ( + + )} +
+ + + ); +} + /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ @@ -1226,86 +1359,19 @@ export function FilesApp({ windowId: _windowId }: { windowId: string }) { - {sortedFiles.map((f) => { - const Icon = getFileIcon(f.name, f.is_dir); - return ( - - - - - - {f.is_dir ? "—" : formatSize(f.size)} - - - {formatDate(f.modified)} - - -
- {!f.is_dir && isWritable && ( - - - - )} - {isWritable && ( - - )} -
- - - ); - })} + {sortedFiles.map((f) => ( + + ))} diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index 3e1aa3e1..925c59f5 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -30,6 +30,7 @@ import { import { MobileSplitView } from "@/components/mobile/MobileSplitView"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useVisualViewport } from "@/hooks/use-visual-viewport"; +import { useDropTarget } from "@/shell/dnd/use-drop-target"; import { resolveAgentEmoji } from "@/lib/agent-emoji"; import { ChannelSettingsPanel } from "./chat/ChannelSettingsPanel"; import { AgentContextMenu } from "./chat/AgentContextMenu"; @@ -209,6 +210,31 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; const { keyboardInset } = useVisualViewport(); const [channels, setChannels] = useState([]); + const shellFileDropTarget = useDropTarget({ + accept: ["file"], + onDrop: async (payload) => { + if (payload.kind !== "file" || !selectedChannel) return; + const ch = allChannels.find((c) => c.id === selectedChannel); + if (ch?.settings?.archived) return; + const id = Math.random().toString(36).slice(2); + setPendingAttachments((p) => [...p, { + id, filename: payload.name, size: payload.size, uploading: true, + }]); + try { + const isAgentWs = payload.path.startsWith("/workspaces/agent/"); + const source: "workspace" | "agent-workspace" = isAgentWs ? "agent-workspace" : "workspace"; + const slug = isAgentWs ? payload.path.split("/")[3] : undefined; + const rec = await attachmentFromPath({ path: payload.path, source, slug }); + setPendingAttachments((p) => + p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x) + ); + } catch (e) { + setPendingAttachments((p) => + p.map((x) => x.id === id ? { ...x, uploading: false, error: (e as Error).message } : x) + ); + } + }, + }); const [archivedChannels, setArchivedChannels] = useState([]); const [archivedExpanded, setArchivedExpanded] = useState(false); const [liveAgents, setLiveAgents] = useState([]); @@ -1297,18 +1323,34 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
0 ? { paddingBottom: `${keyboardInset + 60}px` } : undefined} - onDragOver={(e) => e.preventDefault()} + onDragEnter={shellFileDropTarget.dropHandlers.onDragEnter} + onDragOver={(e) => { + shellFileDropTarget.dropHandlers.onDragOver(e); + if (!e.defaultPrevented) e.preventDefault(); + }} + onDragLeave={shellFileDropTarget.dropHandlers.onDragLeave} onDrop={(e) => { - e.preventDefault(); - for (const f of Array.from(e.dataTransfer.files)) { - const id = Math.random().toString(36).slice(2); - setPendingAttachments((p) => [...p, { id, filename: f.name, size: f.size, uploading: true }]); - uploadDiskFile(f, selectedChannel ?? undefined) - .then((rec) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x))) - .catch((err) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: (err as Error).message } : x))); + // OS-level file drops (finder/explorer) take precedence. + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const f of Array.from(e.dataTransfer.files)) { + const id = Math.random().toString(36).slice(2); + setPendingAttachments((p) => [...p, { id, filename: f.name, size: f.size, uploading: true }]); + uploadDiskFile(f, selectedChannel ?? undefined) + .then((rec) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x))) + .catch((err) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: (err as Error).message } : x))); + } + return; } + shellFileDropTarget.dropHandlers.onDrop(e); }} > {messages.length === 0 && ( diff --git a/desktop/src/shell/dnd/__tests__/dnd-bus.test.ts b/desktop/src/shell/dnd/__tests__/dnd-bus.test.ts new file mode 100644 index 00000000..f7ff30a0 --- /dev/null +++ b/desktop/src/shell/dnd/__tests__/dnd-bus.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { startDrag, endDrag, getCurrent, subscribe } from "../dnd-bus"; + +const samplePayload = { kind: "file" as const, path: "/a/b.png", mime_type: "image/png", size: 10, name: "b.png" }; + +describe("dnd-bus", () => { + beforeEach(() => { + endDrag(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + endDrag(); + }); + + it("startDrag sets current and notifies subscribers", () => { + const fn = vi.fn(); + const unsub = subscribe(fn); + startDrag(samplePayload); + expect(getCurrent()).toEqual(samplePayload); + expect(fn).toHaveBeenCalled(); + unsub(); + }); + + it("endDrag clears current", () => { + startDrag(samplePayload); + endDrag(); + expect(getCurrent()).toBeNull(); + }); + + it("30s stale timeout auto-clears", () => { + startDrag(samplePayload); + expect(getCurrent()).not.toBeNull(); + vi.advanceTimersByTime(30_000); + expect(getCurrent()).toBeNull(); + }); + + it("starting a new drag resets the stale timer", () => { + startDrag(samplePayload); + vi.advanceTimersByTime(25_000); + startDrag({ ...samplePayload, name: "c.png" }); + vi.advanceTimersByTime(15_000); + expect(getCurrent()).not.toBeNull(); + vi.advanceTimersByTime(20_000); + expect(getCurrent()).toBeNull(); + }); + + it("subscribers receive change events for both start and end", () => { + const fn = vi.fn(); + subscribe(fn); + startDrag(samplePayload); + endDrag(); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx b/desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx new file mode 100644 index 00000000..9b54cf8d --- /dev/null +++ b/desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDragSource } from "../use-drag-source"; +import { getCurrent, endDrag } from "../dnd-bus"; + +describe("useDragSource", () => { + beforeEach(() => endDrag()); + + it("onDragStart calls startDrag on the bus", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload })); + const setData = vi.fn(); + const e = { dataTransfer: { setData, effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(getCurrent()).toEqual(payload); + }); + + it("htmlMirror writes each mime via dataTransfer.setData", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ + payload, + htmlMirror: { "text/plain": "/a.txt", "text/uri-list": "https://h/a.txt" }, + })); + const setData = vi.fn(); + const e = { dataTransfer: { setData, effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(setData).toHaveBeenCalledWith("text/plain", "/a.txt"); + expect(setData).toHaveBeenCalledWith("text/uri-list", "https://h/a.txt"); + }); + + it("disabled=true sets draggable=false", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload, disabled: true })); + expect(result.current.dragHandlers.draggable).toBe(false); + }); + + it("onDragEnd clears the bus", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload })); + const e = { dataTransfer: { setData: vi.fn(), effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(getCurrent()).not.toBeNull(); + act(() => { result.current.dragHandlers.onDragEnd(); }); + expect(getCurrent()).toBeNull(); + }); +}); diff --git a/desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx b/desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx new file mode 100644 index 00000000..d5e8e923 --- /dev/null +++ b/desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDropTarget } from "../use-drop-target"; +import { startDrag, endDrag } from "../dnd-bus"; + +const filePayload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; +const msgPayload = { kind: "message" as const, channel_id: "c1", message_id: "m1", author_id: "tom", excerpt: "hi" }; + +describe("useDropTarget", () => { + beforeEach(() => endDrag()); + + it("isValidTarget true when bus payload matches accept", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + expect(result.current.isValidTarget).toBe(false); + act(() => { startDrag(filePayload); }); + rerender(); + expect(result.current.isValidTarget).toBe(true); + }); + + it("isValidTarget false when bus payload type not accepted", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + act(() => { startDrag(msgPayload); }); + rerender(); + expect(result.current.isValidTarget).toBe(false); + }); + + it("onDrop callback fires with payload when valid", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop }), + ); + act(() => { startDrag(filePayload); }); + const e = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDrop(e); }); + expect(e.preventDefault).toHaveBeenCalled(); + expect(onDrop).toHaveBeenCalledWith(filePayload); + }); + + it("onDrop callback does NOT fire when invalid type", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop }), + ); + act(() => { startDrag(msgPayload); }); + const e = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDrop(e); }); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("isOver tracks enter/leave counter for nested children", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + // isOver/preventDefault only react while a valid drag is in flight. + act(() => { startDrag(filePayload); }); + rerender(); + const enter = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDragEnter(enter); }); + rerender(); + expect(result.current.isOver).toBe(true); + act(() => { result.current.dropHandlers.onDragEnter(enter); }); + act(() => { result.current.dropHandlers.onDragLeave(enter); }); + rerender(); + expect(result.current.isOver).toBe(true); + act(() => { result.current.dropHandlers.onDragLeave(enter); }); + rerender(); + expect(result.current.isOver).toBe(false); + }); + + it("isOver stays false when drag payload type is not accepted", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + act(() => { startDrag(msgPayload); }); + rerender(); + const enter = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDragEnter(enter); }); + rerender(); + expect(result.current.isOver).toBe(false); + expect(enter.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/shell/dnd/dnd-bus.ts b/desktop/src/shell/dnd/dnd-bus.ts new file mode 100644 index 00000000..c6655227 --- /dev/null +++ b/desktop/src/shell/dnd/dnd-bus.ts @@ -0,0 +1,41 @@ +import type { DragPayload } from "./types"; + +let current: DragPayload | null = null; +const emitter = new EventTarget(); +let staleTimer: ReturnType | null = null; + +const STALE_MS = 30_000; + +function emit() { + emitter.dispatchEvent(new Event("change")); +} + +export function startDrag(payload: DragPayload): void { + current = payload; + if (staleTimer) clearTimeout(staleTimer); + staleTimer = setTimeout(() => { + current = null; + staleTimer = null; + emit(); + }, STALE_MS); + emit(); +} + +export function endDrag(): void { + if (current === null && staleTimer === null) return; + current = null; + if (staleTimer) { + clearTimeout(staleTimer); + staleTimer = null; + } + emit(); +} + +export function getCurrent(): DragPayload | null { + return current; +} + +export function subscribe(listener: () => void): () => void { + emitter.addEventListener("change", listener); + return () => emitter.removeEventListener("change", listener); +} diff --git a/desktop/src/shell/dnd/types.ts b/desktop/src/shell/dnd/types.ts new file mode 100644 index 00000000..ffe7b857 --- /dev/null +++ b/desktop/src/shell/dnd/types.ts @@ -0,0 +1,7 @@ +export type DragPayload = + | { kind: "file"; path: string; mime_type: string; size: number; name: string } + | { kind: "message"; channel_id: string; message_id: string; author_id: string; excerpt: string } + | { kind: "knowledge"; id: string; title: string; url?: string } + | { kind: "canvas-block"; canvas_id: string; block_id: string; block_type: string }; + +export type DragKind = DragPayload["kind"]; diff --git a/desktop/src/shell/dnd/use-drag-source.ts b/desktop/src/shell/dnd/use-drag-source.ts new file mode 100644 index 00000000..f0bafd62 --- /dev/null +++ b/desktop/src/shell/dnd/use-drag-source.ts @@ -0,0 +1,37 @@ +import type { DragPayload } from "./types"; +import { startDrag, endDrag } from "./dnd-bus"; + +export interface UseDragSourceOpts { + payload: T; + disabled?: boolean; + htmlMirror?: Record; +} + +export function useDragSource(opts: UseDragSourceOpts) { + const { payload, disabled = false, htmlMirror } = opts; + return { + dragHandlers: { + draggable: !disabled, + onDragStart: (e: React.DragEvent) => { + if (disabled) return; + // Defensive: clear any stale payload from a previous drag whose + // dragend didn't fire (pointer capture lost, iframe escape). + // startDrag resets the timer too, but this keeps the bus single- + // source-of-truth clean. + endDrag(); + try { + e.dataTransfer.effectAllowed = "copy"; + if (htmlMirror) { + for (const [mime, value] of Object.entries(htmlMirror)) { + try { e.dataTransfer.setData(mime, value); } catch { /* best-effort */ } + } + } + } catch { /* ignore */ } + startDrag(payload); + }, + onDragEnd: () => { + endDrag(); + }, + }, + }; +} diff --git a/desktop/src/shell/dnd/use-drop-target.ts b/desktop/src/shell/dnd/use-drop-target.ts new file mode 100644 index 00000000..021eaf2c --- /dev/null +++ b/desktop/src/shell/dnd/use-drop-target.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { DragKind, DragPayload } from "./types"; +import { endDrag, getCurrent, subscribe } from "./dnd-bus"; + +export interface UseDropTargetOpts { + accept: DragKind[]; + onDrop: (payload: DragPayload) => void | Promise; + disabled?: boolean; +} + +export function useDropTarget(opts: UseDropTargetOpts) { + const { accept, onDrop, disabled = false } = opts; + const current = useSyncExternalStore(subscribe, getCurrent, () => null); + const enterCounter = useRef(0); + const [isOver, setIsOver] = useState(false); + + const isValidTarget = !disabled && current !== null && accept.includes(current.kind); + + useEffect(() => { + if (current === null) { + enterCounter.current = 0; + setIsOver(false); + } + }, [current]); + + return { + isOver, + isValidTarget, + dropHandlers: { + onDragEnter: (e: React.DragEvent) => { + // Only react when the in-flight drag is a type we accept; this keeps + // unrelated drags from highlighting this target or stealing default. + if (disabled || !isValidTarget) return; + e.preventDefault(); + enterCounter.current += 1; + if (enterCounter.current === 1) setIsOver(true); + }, + onDragOver: (e: React.DragEvent) => { + if (disabled || !isValidTarget) return; + e.preventDefault(); + }, + onDragLeave: (_e: React.DragEvent) => { + if (disabled || !isValidTarget) return; + enterCounter.current = Math.max(0, enterCounter.current - 1); + if (enterCounter.current === 0) setIsOver(false); + }, + onDrop: (e: React.DragEvent) => { + enterCounter.current = 0; + setIsOver(false); + const payload = getCurrent(); + if (!disabled && payload && accept.includes(payload.kind)) { + e.preventDefault(); + try { + const r = onDrop(payload); + if (r && typeof (r as Promise).then === "function") { + (r as Promise).catch((err) => console.warn("drop handler failed", err)); + } + } catch (err) { + console.warn("drop handler failed", err); + } + // Clear bus now — don't wait for the 30s stale timer. Firefox also + // fires dragend after drop but other browsers can skip it. + endDrag(); + } + }, + }, + }; +} diff --git a/desktop/tsconfig.tsbuildinfo b/desktop/tsconfig.tsbuildinfo index 48b404de..611c1be1 100644 --- a/desktop/tsconfig.tsbuildinfo +++ b/desktop/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/browserapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/settingsapp.tsx","./src/apps/storeapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/topbar.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/browserapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/settingsapp.tsx","./src/apps/storeapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/topbar.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/shell/dnd/dnd-bus.ts","./src/shell/dnd/types.ts","./src/shell/dnd/use-drag-source.ts","./src/shell/dnd/use-drop-target.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-20-shell-cross-app-dnd.md b/docs/superpowers/plans/2026-04-20-shell-cross-app-dnd.md new file mode 100644 index 00000000..dc9e9b6c --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-shell-cross-app-dnd.md @@ -0,0 +1,644 @@ +# Shell Cross-App Drag-Drop Phase 1 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended). + +**Goal:** Land the shell drag-drop primitive (hybrid in-memory bus + HTML5 mirror), then wire FilesApp as a source and MessagesApp composer/attachments bar as a target. Further source/target wiring (Messages→TextEditor, Library, Canvas) follow in subsequent tasks. + +**Architecture:** Singleton event-emitter bus + two React hooks (`useDragSource`, `useDropTarget`). Native HTML5 drag for cross-origin/OS drags, rich typed payloads for in-app. + +**Tech Stack:** React + TypeScript + Vitest + Playwright. + +--- + +## File structure + +### New +- `desktop/src/shell/dnd/types.ts` — `DragPayload` union + `DragKind`. +- `desktop/src/shell/dnd/dnd-bus.ts` — singleton state + pub/sub. +- `desktop/src/shell/dnd/use-drag-source.ts` — hook. +- `desktop/src/shell/dnd/use-drop-target.ts` — hook. +- `desktop/src/shell/dnd/__tests__/dnd-bus.test.ts` +- `desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx` +- `desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx` +- `tests/e2e/test_cross_app_dnd.py` + +### Modified +- `desktop/src/apps/FilesApp.tsx` — file rows as drag sources. +- `desktop/src/apps/MessagesApp.tsx` — composer + attachments bar as drop targets. +- `desktop/src/apps/chat/MessageHoverActions.tsx` — add message drag handle. +- `desktop/src/apps/LibraryApp.tsx` — knowledge rows as drag sources. +- `desktop/src/apps/CanvasApp.tsx` (if canvas edits are in scope) — blocks as sources, canvas surface as target. +- `desktop/src/apps/TextEditorApp.tsx` — drop target. + +--- + +## Task 1: Shell DnD primitives (bus + hooks + types) + +**Files:** +- Create: `desktop/src/shell/dnd/types.ts` +- Create: `desktop/src/shell/dnd/dnd-bus.ts` +- Create: `desktop/src/shell/dnd/use-drag-source.ts` +- Create: `desktop/src/shell/dnd/use-drop-target.ts` +- Create: `desktop/src/shell/dnd/__tests__/dnd-bus.test.ts` +- Create: `desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx` +- Create: `desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx` + +### Step 1: Types + +`desktop/src/shell/dnd/types.ts`: + +```typescript +export type DragPayload = + | { kind: "file"; path: string; mime_type: string; size: number; name: string } + | { kind: "message"; channel_id: string; message_id: string; author_id: string; excerpt: string } + | { kind: "knowledge"; id: string; title: string; url?: string } + | { kind: "canvas-block"; canvas_id: string; block_id: string; block_type: string }; + +export type DragKind = DragPayload["kind"]; +``` + +### Step 2: Bus tests + +`desktop/src/shell/dnd/__tests__/dnd-bus.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { startDrag, endDrag, getCurrent, subscribe } from "../dnd-bus"; + +const samplePayload = { kind: "file" as const, path: "/a/b.png", mime_type: "image/png", size: 10, name: "b.png" }; + +describe("dnd-bus", () => { + beforeEach(() => { + endDrag(); // reset state + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + endDrag(); + }); + + it("startDrag sets current and notifies subscribers", () => { + const fn = vi.fn(); + const unsub = subscribe(fn); + startDrag(samplePayload); + expect(getCurrent()).toEqual(samplePayload); + expect(fn).toHaveBeenCalled(); + unsub(); + }); + + it("endDrag clears current", () => { + startDrag(samplePayload); + endDrag(); + expect(getCurrent()).toBeNull(); + }); + + it("30s stale timeout auto-clears", () => { + startDrag(samplePayload); + expect(getCurrent()).not.toBeNull(); + vi.advanceTimersByTime(30_000); + expect(getCurrent()).toBeNull(); + }); + + it("starting a new drag resets the stale timer", () => { + startDrag(samplePayload); + vi.advanceTimersByTime(25_000); + startDrag({ ...samplePayload, name: "c.png" }); + vi.advanceTimersByTime(15_000); // 15s after restart + expect(getCurrent()).not.toBeNull(); + vi.advanceTimersByTime(20_000); // 35s after restart total + expect(getCurrent()).toBeNull(); + }); + + it("subscribers receive change events for both start and end", () => { + const fn = vi.fn(); + subscribe(fn); + startDrag(samplePayload); + endDrag(); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); +``` + +### Step 3: Run tests → FAIL (module missing) + +Run: `cd desktop && npm test -- --run dnd-bus` + +### Step 4: Implement `dnd-bus.ts` + +```typescript +import type { DragPayload } from "./types"; + +let current: DragPayload | null = null; +const emitter = new EventTarget(); +let staleTimer: ReturnType | null = null; + +const STALE_MS = 30_000; + +function emit() { + emitter.dispatchEvent(new Event("change")); +} + +export function startDrag(payload: DragPayload): void { + current = payload; + if (staleTimer) clearTimeout(staleTimer); + staleTimer = setTimeout(() => { + current = null; + staleTimer = null; + emit(); + }, STALE_MS); + emit(); +} + +export function endDrag(): void { + if (current === null && staleTimer === null) return; + current = null; + if (staleTimer) { + clearTimeout(staleTimer); + staleTimer = null; + } + emit(); +} + +export function getCurrent(): DragPayload | null { + return current; +} + +export function subscribe(listener: () => void): () => void { + emitter.addEventListener("change", listener); + return () => emitter.removeEventListener("change", listener); +} +``` + +### Step 5: Run tests → 5 pass + +### Step 6: `use-drag-source` tests + +`desktop/src/shell/dnd/__tests__/use-drag-source.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDragSource } from "../use-drag-source"; +import { getCurrent, endDrag } from "../dnd-bus"; + +describe("useDragSource", () => { + beforeEach(() => endDrag()); + + it("onDragStart calls startDrag on the bus", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload })); + const setData = vi.fn(); + const e = { dataTransfer: { setData, effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(getCurrent()).toEqual(payload); + }); + + it("htmlMirror writes each mime via dataTransfer.setData", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ + payload, + htmlMirror: { "text/plain": "/a.txt", "text/uri-list": "https://h/a.txt" }, + })); + const setData = vi.fn(); + const e = { dataTransfer: { setData, effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(setData).toHaveBeenCalledWith("text/plain", "/a.txt"); + expect(setData).toHaveBeenCalledWith("text/uri-list", "https://h/a.txt"); + }); + + it("disabled=true sets draggable=false", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload, disabled: true })); + expect(result.current.dragHandlers.draggable).toBe(false); + }); + + it("onDragEnd clears the bus", () => { + const payload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; + const { result } = renderHook(() => useDragSource({ payload })); + const e = { dataTransfer: { setData: vi.fn(), effectAllowed: "" } } as unknown as React.DragEvent; + act(() => { result.current.dragHandlers.onDragStart(e); }); + expect(getCurrent()).not.toBeNull(); + act(() => { result.current.dragHandlers.onDragEnd(); }); + expect(getCurrent()).toBeNull(); + }); +}); +``` + +### Step 7: Run tests → FAIL + +### Step 8: Implement `use-drag-source.ts` + +```typescript +import type { DragPayload } from "./types"; +import { startDrag, endDrag } from "./dnd-bus"; + +export interface UseDragSourceOpts { + payload: T; + disabled?: boolean; + htmlMirror?: Record; +} + +export function useDragSource(opts: UseDragSourceOpts) { + const { payload, disabled = false, htmlMirror } = opts; + return { + dragHandlers: { + draggable: !disabled, + onDragStart: (e: React.DragEvent) => { + if (disabled) return; + try { + e.dataTransfer.effectAllowed = "copy"; + if (htmlMirror) { + for (const [mime, value] of Object.entries(htmlMirror)) { + try { e.dataTransfer.setData(mime, value); } catch { /* best-effort */ } + } + } + } catch { /* ignore */ } + startDrag(payload); + }, + onDragEnd: () => { + endDrag(); + }, + }, + }; +} +``` + +### Step 9: Run tests → 4 pass + +### Step 10: `use-drop-target` tests + +`desktop/src/shell/dnd/__tests__/use-drop-target.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDropTarget } from "../use-drop-target"; +import { startDrag, endDrag } from "../dnd-bus"; + +const filePayload = { kind: "file" as const, path: "/a.txt", mime_type: "text/plain", size: 10, name: "a.txt" }; +const msgPayload = { kind: "message" as const, channel_id: "c1", message_id: "m1", author_id: "tom", excerpt: "hi" }; + +describe("useDropTarget", () => { + beforeEach(() => endDrag()); + + it("isValidTarget true when bus payload matches accept", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + expect(result.current.isValidTarget).toBe(false); + act(() => { startDrag(filePayload); }); + rerender(); + expect(result.current.isValidTarget).toBe(true); + }); + + it("isValidTarget false when bus payload type not accepted", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + act(() => { startDrag(msgPayload); }); + rerender(); + expect(result.current.isValidTarget).toBe(false); + }); + + it("onDrop callback fires with payload when valid", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop }), + ); + act(() => { startDrag(filePayload); }); + const e = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDrop(e); }); + expect(e.preventDefault).toHaveBeenCalled(); + expect(onDrop).toHaveBeenCalledWith(filePayload); + }); + + it("onDrop callback does NOT fire when invalid type", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop }), + ); + act(() => { startDrag(msgPayload); }); + const e = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDrop(e); }); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("isOver tracks enter/leave counter for nested children", () => { + const { result, rerender } = renderHook(() => + useDropTarget({ accept: ["file"], onDrop: vi.fn() }), + ); + const enter = { preventDefault: vi.fn() } as unknown as React.DragEvent; + act(() => { result.current.dropHandlers.onDragEnter(enter); }); + rerender(); + expect(result.current.isOver).toBe(true); + // simulate enter on a nested child (double enter before leave) + act(() => { result.current.dropHandlers.onDragEnter(enter); }); + act(() => { result.current.dropHandlers.onDragLeave(enter); }); + rerender(); + expect(result.current.isOver).toBe(true); // still over (parent still holds) + act(() => { result.current.dropHandlers.onDragLeave(enter); }); + rerender(); + expect(result.current.isOver).toBe(false); + }); +}); +``` + +### Step 11: Run tests → FAIL + +### Step 12: Implement `use-drop-target.ts` + +```typescript +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { DragKind, DragPayload } from "./types"; +import { getCurrent, subscribe } from "./dnd-bus"; + +export interface UseDropTargetOpts { + accept: DragKind[]; + onDrop: (payload: DragPayload) => void | Promise; + disabled?: boolean; +} + +export function useDropTarget(opts: UseDropTargetOpts) { + const { accept, onDrop, disabled = false } = opts; + const current = useSyncExternalStore(subscribe, getCurrent, () => null); + const enterCounter = useRef(0); + const [isOver, setIsOver] = useState(false); + + const isValidTarget = !disabled && current !== null && accept.includes(current.kind); + + useEffect(() => { + if (current === null) { + enterCounter.current = 0; + setIsOver(false); + } + }, [current]); + + return { + isOver, + isValidTarget, + dropHandlers: { + onDragEnter: (e: React.DragEvent) => { + if (disabled) return; + e.preventDefault(); + enterCounter.current += 1; + if (enterCounter.current === 1) setIsOver(true); + }, + onDragOver: (e: React.DragEvent) => { + if (disabled) return; + e.preventDefault(); + }, + onDragLeave: (_e: React.DragEvent) => { + if (disabled) return; + enterCounter.current = Math.max(0, enterCounter.current - 1); + if (enterCounter.current === 0) setIsOver(false); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + enterCounter.current = 0; + setIsOver(false); + const payload = getCurrent(); + if (!disabled && payload && accept.includes(payload.kind)) { + try { + const r = onDrop(payload); + if (r && typeof (r as Promise).then === "function") { + (r as Promise).catch((err) => console.warn("drop handler failed", err)); + } + } catch (err) { + console.warn("drop handler failed", err); + } + } + }, + }, + }; +} +``` + +### Step 13: Run all 3 test files → 14 pass + +```bash +cd desktop && npm test -- --run dnd +``` + +### Step 14: Build + +```bash +cd desktop && npm run build +``` +Expected: clean. + +### Step 15: Commit + +```bash +git add desktop/src/shell/dnd +git commit -m "feat(shell): cross-app DnD primitives — bus, useDragSource, useDropTarget" +``` + +--- + +## Task 2: FilesApp source + MessagesApp target + +**Files:** +- Modify: `desktop/src/apps/FilesApp.tsx` — add `useDragSource` on file rows. +- Modify: `desktop/src/apps/MessagesApp.tsx` — add `useDropTarget` on composer + attachments bar; handle file-kind payload. + +### Step 1: FilesApp source + +Locate the file row render (grep for `draggable` or `onClick={() => handleRowClick(` in `FilesApp.tsx`). For each file row `