Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 146 additions & 80 deletions desktop/src/apps/FilesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -227,6 +228,138 @@ async function apiFetch<T>(url: string, opts?: RequestInit): Promise<T> {
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: When vfsPath is null, this passes an empty string as path in the drag payload (due to ?? ""). Empty paths will fail validation in drop targets.

The payload should never have an empty path - ensure vfsPath is always valid when dragEnabled is true.

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,
});
Comment on lines +246 to +280
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire the drag source into grid view too.

useDragSource() only gets applied through FileRow, and FileRow is only rendered in list view. Since FilesApp still defaults to grid view, the new Files → Messages flow is unavailable in the default layout until users manually switch views. Reuse the same drag config on the grid cards as well.

Also applies to: 1362-1374

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/FilesApp.tsx` around lines 246 - 280, The grid view doesn't
get the drag source because useDragSource and its dragHandlers are only used
inside FileRow (list view); update FilesApp so the same drag setup is used for
grid cards: extract the vfsPath/dragEnabled logic and the useDragSource call
(payload, disabled, htmlMirror) from FileRow into a reusable spot (or compute
them in the parent where grid/list rendering happens), then attach the resulting
dragHandlers to the grid card wrapper element (the component rendering file
cards in grid view) the same way FileRow attaches them. Ensure you reuse the
same symbols: compute vfsPath (workspace or agent via
agentSlug/isAgentLocation), set dragEnabled = !!vfsPath && !f.is_dir, call
useDragSource with the same payload fields (kind, path, mime_type, size, name)
and pass dragHandlers to the grid card so grid view supports the identical drag
behavior.


return (
<tr
key={f.path || f.name}
data-file-row
className="border-b border-white/5 hover:bg-shell-surface/50 transition-colors group"
{...dragHandlers}
>
<td className="px-3 py-2">
<button
onClick={() => {
if (f.is_dir) {
navigateTo(f.path || (currentPath ? `${currentPath}/${f.name}` : f.name));
}
}}
className="flex items-center gap-2 min-w-0"
aria-label={f.is_dir ? `Open folder ${f.name}` : `File ${f.name}`}
>
{!f.is_dir && isImage(f.name) ? (
<img
src={fileUrl(location, f.path || f.name)}
alt=""
loading="lazy"
decoding="async"
className="w-6 h-6 rounded object-cover border border-white/[0.06] shrink-0"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<Icon size={16} className={f.is_dir ? "text-accent shrink-0" : "text-shell-text-secondary shrink-0"} />
)}
<span className="truncate">{f.name}</span>
</button>
</td>
<td className="px-3 py-2 text-shell-text-tertiary">
{f.is_dir ? "—" : formatSize(f.size)}
</td>
<td className="px-3 py-2 text-shell-text-tertiary">
{formatDate(f.modified)}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{!f.is_dir && isWritable && (
<a
href={fileUrl(location, f.path || f.name)}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-shell-surface transition-all text-shell-text-tertiary hover:text-shell-text"
aria-label={`Download ${f.name}`}
>
<Download size={13} />
</a>
)}
{isWritable && (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (deleteConfirm === f.path) {
handleDelete(f.path);
} else {
setDeleteConfirm(f.path);
}
}}
className={`h-7 w-7 transition-all ${
deleteConfirm === f.path
? "bg-red-500/20 text-red-400 opacity-100 hover:bg-red-500/25 hover:text-red-400"
: "opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
}`}
aria-label={deleteConfirm === f.path ? `Confirm delete ${f.name}` : `Delete ${f.name}`}
title={deleteConfirm === f.path ? "Click again to confirm" : "Delete"}
>
<Trash2 size={13} />
</Button>
)}
</div>
</td>
</tr>
);
}

/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -1226,86 +1359,19 @@ export function FilesApp({ windowId: _windowId }: { windowId: string }) {
</tr>
</thead>
<tbody>
{sortedFiles.map((f) => {
const Icon = getFileIcon(f.name, f.is_dir);
return (
<tr
key={f.path || f.name}
className="border-b border-white/5 hover:bg-shell-surface/50 transition-colors group"
>
<td className="px-3 py-2">
<button
onClick={() => {
if (f.is_dir) {
navigateTo(f.path || (currentPath ? `${currentPath}/${f.name}` : f.name));
}
}}
className="flex items-center gap-2 min-w-0"
aria-label={f.is_dir ? `Open folder ${f.name}` : `File ${f.name}`}
>
{!f.is_dir && isImage(f.name) ? (
<img
src={fileUrl(location, f.path || f.name)}
alt=""
loading="lazy"
decoding="async"
className="w-6 h-6 rounded object-cover border border-white/[0.06] shrink-0"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<Icon size={16} className={f.is_dir ? "text-accent shrink-0" : "text-shell-text-secondary shrink-0"} />
)}
<span className="truncate">{f.name}</span>
</button>
</td>
<td className="px-3 py-2 text-shell-text-tertiary">
{f.is_dir ? "—" : formatSize(f.size)}
</td>
<td className="px-3 py-2 text-shell-text-tertiary">
{formatDate(f.modified)}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{!f.is_dir && isWritable && (
<a
href={fileUrl(location, f.path || f.name)}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-shell-surface transition-all text-shell-text-tertiary hover:text-shell-text"
aria-label={`Download ${f.name}`}
>
<Download size={13} />
</a>
)}
{isWritable && (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (deleteConfirm === f.path) {
handleDelete(f.path);
} else {
setDeleteConfirm(f.path);
}
}}
className={`h-7 w-7 transition-all ${
deleteConfirm === f.path
? "bg-red-500/20 text-red-400 opacity-100 hover:bg-red-500/25 hover:text-red-400"
: "opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
}`}
aria-label={deleteConfirm === f.path ? `Confirm delete ${f.name}` : `Delete ${f.name}`}
title={deleteConfirm === f.path ? "Click again to confirm" : "Delete"}
>
<Trash2 size={13} />
</Button>
)}
</div>
</td>
</tr>
);
})}
{sortedFiles.map((f) => (
<FileRow
key={f.path || f.name}
f={f}
location={location}
currentPath={currentPath}
navigateTo={navigateTo}
isWritable={isWritable}
deleteConfirm={deleteConfirm}
handleDelete={handleDelete}
setDeleteConfirm={setDeleteConfirm}
/>
))}
</tbody>
</table>
</div>
Expand Down
60 changes: 51 additions & 9 deletions desktop/src/apps/MessagesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -209,6 +210,31 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
const { keyboardInset } = useVisualViewport();

const [channels, setChannels] = useState<Channel[]>([]);
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<Channel[]>([]);
const [archivedExpanded, setArchivedExpanded] = useState(false);
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
Expand Down Expand Up @@ -1297,18 +1323,34 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
<div
ref={messageListRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5"
className={`flex-1 overflow-y-auto px-4 py-3 space-y-0.5 message-list-drop-target ${
shellFileDropTarget.isOver
? "ring-2 ring-sky-400/60 ring-inset bg-sky-500/5"
: shellFileDropTarget.isValidTarget
? "ring-2 ring-sky-400/30 ring-inset"
: ""
}`}
style={isMobile && keyboardInset > 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 && (
Expand Down
55 changes: 55 additions & 0 deletions desktop/src/shell/dnd/__tests__/dnd-bus.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading