file browser: close the accidental gaps in the context menu

The audit split the menu's omissions in two. Three were deliberate — OCR,
Transcribe and Extract Audio were removed in July because the task system does
them better, and those stay gone. The rest were nobody's decision. This is the
rest.

── One menu body instead of two ──

`DropdownMenuItems` and `ContextMenuItems` were character-identical apart from the
component prefix: 159 lines duplicated, including the same comment twice. Radix
gives both families the same props, so the components are now a parameter and
there is one list. That is why this diff removes more than it adds while adding
seven items — and why the next item only has to be written once.

── Added to the row menu ──

- Open — double-click was the only way. Every file manager puts it first.
- Open in editor — `/code-editor` is on the SAME `files` permission as `/files`,
  so anyone who can browse could already edit; there was just no way to get there
  from a file. Narrower than Read Aloud on purpose: that one uses the `text`
  FALLBACK type, which matches .exe and .bin.
- Paste into folder — a move was only expressible as cut → navigate → paste.
  Right-clicking the destination is the obvious gesture and did not exist.
- Duplicate — one call away the whole time; `/copy` already resolves collisions
  to " (copy 2)".
- Compress — the missing half of Extract. `/download` shelled out to zip but
  streamed the result away, so nothing could make an archive and keep it. New
  `POST /file-browser/compress` writes it beside the original.
- Pin / Unpin — pinning existed only in the widget, which is not where you meet
  a file worth keeping to hand.

── Added to the background menu ──

- Clone repository and Show/Hide hidden files. Both were toolbar-only, and the
  toolbar hides them under `md:` — so on a phone neither existed. All four
  desktop-only toolbar actions are now in this menu, which long-press reaches.
- Paste is gated on the clipboard, as both toolbars already were. Ungated it fell
  through to the OS clipboard path and either toasted a secure-context error or
  silently did nothing — an item that looked available and was not.

── Removed ──

`pcm-worklet-processor.js`, orphaned when cliamp left for the music plugin this
morning. Verified unreferenced; the plugin ships its own copy inline.

The `Play` hole itself is NOT closed and cannot be from here: there is no way for
a plugin to register a context-menu item. Tasks and agents are the only extension
mechanism and a task can only contribute a task entry. That is a platform gap and
wants its own decision.

Verified the zip invocation by hand for all three shapes — single file, directory
(recursive, contents included), and a multi-item selection. Archive naming strips
the extension only for a file, so a directory called `my.photos` stays
`my.photos.zip` rather than becoming `my.zip`.

tsgo clean, frontend builds, 808 pass / 7 fail unchanged.

SelectionActions.tsx and usePipelineRunner.ts are prettier reflowing long lines —
no semantic change, caught by formatting the folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:09:57 +00:00
co-authored by Claude Opus 5
parent 640529ccba
commit cc889a864c
9 changed files with 681 additions and 478 deletions
+53
View File
@@ -1498,6 +1498,59 @@ router.post('/download', async (ctx) => {
}); });
}); });
/**
* Compress items into a zip ON DISK, beside them.
*
* Distinct from GET/POST /download, which also shell out to `zip` but stream the result to the browser
* and leave nothing behind. There was no way to make an archive and keep it — the file browser could
* unpack an archive (`/extract`) but not create one, which is a strange half of a pair.
*
* The name comes from the selection: one item gives `<name>.zip`, several give `archive.zip`, and
* `resolveCollision` handles the rest — so compressing twice yields `archive (copy).zip` rather than
* silently overwriting the first one.
*
* `-r` because a directory has to go in whole, and paths are relative to `rootDir` so the archive holds
* `photos/a.jpg` rather than the absolute path of somebody's home directory.
*/
router.post('/compress', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { paths } = ctx.get('body') as { paths: string[] };
if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required');
const items: string[] = [];
let onlyIsDir = false;
for (const p of paths) {
const relPath = p.replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
const st = await stat(absPath); // throws if not found
onlyIsDir = st.isDirectory();
items.push(relPath);
}
// Everything must sit in one directory for the archive to land beside it, which the UI guarantees —
// a selection is always within the folder being viewed. Checked rather than assumed.
const parents = new Set(items.map((i) => dirname(resolveUserPath(rootDir, i))));
if (parents.size !== 1) throw errors.BAD_REQUEST('all items must be in the same folder');
const parent = [...parents][0]!;
// Strip the extension only for a FILE: `report.pdf` → `report.zip`, but a directory called
// `my.photos` must stay `my.photos.zip` rather than becoming `my.zip`.
const only = items.length === 1 ? (items[0]!.split('/').pop() ?? 'archive') : null;
const base = only === null ? 'archive' : onlyIsDir ? only : only.replace(/\.[^.]+$/, '');
const target = await resolveCollision(resolve(parent, `${base}.zip`));
const proc = Bun.spawn(['zip', '-r', '-q', target, ...items.map((i) => i.split('/').pop()!)], {
cwd: parent,
stdout: 'ignore',
stderr: 'pipe',
});
const err = await new Response(proc.stderr).text();
if ((await proc.exited) !== 0) throw errors.BAD_REQUEST(`zip failed: ${err.trim().slice(-200)}`);
return ctx.json({ ok: true, path: target.slice(rootDir.length), name: target.split('/').pop() });
});
// Delete file or directory // Delete file or directory
router.delete('/rm', async (ctx) => { router.delete('/rm', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
@@ -71,6 +71,12 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
handleRunTask, handleRunTask,
handleRunAgent, handleRunAgent,
handleCreateDashboard, handleCreateDashboard,
handleOpenInEditor,
handleCompress,
handleDuplicate,
handlePasteInto,
handleTogglePin,
isPinned,
fileScrollRef: scrollRef, fileScrollRef: scrollRef,
setSearchQuery, setSearchQuery,
searchInputRef, searchInputRef,
@@ -248,6 +254,13 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
agentGroups={getMatchingAgentGroups(entry.name, entry.type)} agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
onRunAgent={handleRunAgent} onRunAgent={handleRunAgent}
onCreateDashboard={handleCreateDashboard} onCreateDashboard={handleCreateDashboard}
onOpenInEditor={handleOpenInEditor}
onCompress={handleCompress}
onDuplicate={handleDuplicate}
onPasteInto={handlePasteInto}
onTogglePin={handleTogglePin}
isPinned={isPinned(entryPath(entry.name))}
canPaste={clipboard !== null}
/> />
); );
@@ -14,7 +14,15 @@ import {
Volume2, Volume2,
FolderArchive, FolderArchive,
ClipboardCopy, ClipboardCopy,
ClipboardPaste,
Bot, Bot,
FolderOpen,
ExternalLink,
Code,
CopyPlus,
FileArchive,
Pin,
PinOff,
} from 'lucide-react'; } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { import {
@@ -68,6 +76,13 @@ export type FileItemProps = {
agentGroups: AgentGroup[]; agentGroups: AgentGroup[];
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void; onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void; onCreateDashboard: (entry: DirEntry) => void;
onOpenInEditor: (entry: DirEntry) => void;
onCompress: (entry: DirEntry) => void;
onDuplicate: (entry: DirEntry) => void;
onPasteInto: (entry: DirEntry) => void;
onTogglePin: (entry: DirEntry) => void;
isPinned: boolean;
canPaste: boolean;
}; };
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@@ -84,9 +99,21 @@ function formatDate(ms: number): string {
}); });
} }
type MenuPrimitives = {
Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>;
Separator: React.ComponentType<Record<string, never>>;
Sub: React.ComponentType<{ children: React.ReactNode }>;
SubTrigger: React.ComponentType<{ className?: string; children: React.ReactNode }>;
SubContent: React.ComponentType<{ className?: string; children: React.ReactNode }>;
};
type MenuItemsProps = { type MenuItemsProps = {
entry: DirEntry; entry: DirEntry;
multiSelected: boolean; multiSelected: boolean;
isPinned: boolean;
canPaste: boolean;
onOpen: (e: DirEntry) => void;
onOpenInEditor: (e: DirEntry) => void;
onDelete: (e: DirEntry) => void; onDelete: (e: DirEntry) => void;
onStartRename: () => void; onStartRename: () => void;
onChat: (e: DirEntry) => void; onChat: (e: DirEntry) => void;
@@ -94,6 +121,10 @@ type MenuItemsProps = {
onDownload: (e: DirEntry) => void; onDownload: (e: DirEntry) => void;
onReadAloud: (e: DirEntry) => void; onReadAloud: (e: DirEntry) => void;
onExtract: (e: DirEntry) => void; onExtract: (e: DirEntry) => void;
onCompress: (e: DirEntry) => void;
onDuplicate: (e: DirEntry) => void;
onPasteInto: (e: DirEntry) => void;
onTogglePin: (e: DirEntry) => void;
onCut: () => void; onCut: () => void;
onCopy: () => void; onCopy: () => void;
taskGroups: TaskGroup[]; taskGroups: TaskGroup[];
@@ -103,9 +134,21 @@ type MenuItemsProps = {
onCreateDashboard: (e: DirEntry) => void; onCreateDashboard: (e: DirEntry) => void;
}; };
const DropdownMenuItems = ({ /**
* The row menu, once.
*
* This body existed twice — character-identical apart from the `ContextMenu*`/`DropdownMenu*` prefix —
* so every change had to be made in both and the two drifting apart was a matter of time. Radix gives
* the two families the same props, so the components are a parameter and there is one list again.
*/
const MenuItems = ({
ui: { Item, Separator, Sub, SubTrigger, SubContent },
entry, entry,
multiSelected, multiSelected,
isPinned,
canPaste,
onOpen,
onOpenInEditor,
onDelete, onDelete,
onStartRename, onStartRename,
onChat, onChat,
@@ -113,6 +156,10 @@ const DropdownMenuItems = ({
onDownload, onDownload,
onReadAloud, onReadAloud,
onExtract, onExtract,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
onCut, onCut,
onCopy, onCopy,
taskGroups, taskGroups,
@@ -120,10 +167,15 @@ const DropdownMenuItems = ({
agentGroups, agentGroups,
onRunAgent, onRunAgent,
onCreateDashboard, onCreateDashboard,
}: MenuItemsProps) => { }: MenuItemsProps & { ui: MenuPrimitives }) => {
const isDir = entry.type === 'directory';
const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showExtract = fileType === 'archive'; const showExtract = fileType === 'archive';
// Editable in the code editor. Deliberately narrower than `showReadAloud`, which uses the same set
// plus the `text` FALLBACK — that fallback matches .exe and .bin, and opening those in a text editor
// is worse than not offering it.
const showEdit = fileType === 'markdown' || fileType === 'code';
const hasTasks = taskGroups.length > 0; const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.length > 0; const hasAgents = agentGroups.length > 0;
@@ -137,289 +189,173 @@ const DropdownMenuItems = ({
return ( return (
<> <>
{/* Opening was double-click only, with no menu entry at all — the one action every file manager
puts first. */}
<Item onClick={() => onOpen(entry)} className="cursor-pointer">
{isDir ? <FolderOpen className="mr-2 h-4 w-4" /> : <ExternalLink className="mr-2 h-4 w-4" />}
Open
</Item>
{showEdit && (
<Item onClick={() => onOpenInEditor(entry)} className="cursor-pointer">
<Code className="mr-2 h-4 w-4" />
Open in editor
</Item>
)}
{/* Paste INTO a folder. The background menu could only paste into the folder already open, so a
move meant cut → navigate → paste. Hidden rather than disabled when the clipboard is empty. */}
{isDir && canPaste && (
<Item onClick={() => onPasteInto(entry)} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste into folder
</Item>
)}
<Separator />
{showReadAloud && ( {showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer"> <Item onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" /> <Volume2 className="mr-2 h-4 w-4" />
Read Aloud Read Aloud
</DropdownMenuItem> </Item>
)} )}
{showExtract && ( {showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer"> <Item onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" /> <FolderArchive className="mr-2 h-4 w-4" />
Extract Extract
</DropdownMenuItem> </Item>
)} )}
{hasTasks && ( {hasTasks && (
<DropdownMenuSub> <Sub>
<DropdownMenuSubTrigger className="cursor-pointer"> <SubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Run Task Run Task
</DropdownMenuSubTrigger> </SubTrigger>
<DropdownMenuSubContent className="z-[600]"> <SubContent className="z-[600]">
{nestTasks {nestTasks
? taskGroups.map((group) => ( ? taskGroups.map((group) => (
<DropdownMenuSub key={group.category}> <Sub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger> <SubTrigger className="cursor-pointer">{group.category}</SubTrigger>
<DropdownMenuSubContent className="z-[600]"> <SubContent className="z-[600]">
{group.tasks.map((task) => ( {group.tasks.map((task) => (
<DropdownMenuItem <Item key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name} {task.name}
</DropdownMenuItem> </Item>
))} ))}
</DropdownMenuSubContent> </SubContent>
</DropdownMenuSub> </Sub>
)) ))
: flatTasks.map((task) => ( : flatTasks.map((task) => (
<DropdownMenuItem <Item key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name} {task.name}
</DropdownMenuItem> </Item>
))} ))}
</DropdownMenuSubContent> </SubContent>
</DropdownMenuSub> </Sub>
)} )}
{hasAgents && ( {hasAgents && (
<DropdownMenuSub> <Sub>
<DropdownMenuSubTrigger className="cursor-pointer"> <SubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" /> <Bot className="mr-2 h-4 w-4" />
Run Agent Run Agent
</DropdownMenuSubTrigger> </SubTrigger>
<DropdownMenuSubContent className="z-[600]"> <SubContent className="z-[600]">
{nestAgents {nestAgents
? agentGroups.map((group) => ( ? agentGroups.map((group) => (
<DropdownMenuSub key={group.category}> <Sub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger> <SubTrigger className="cursor-pointer">{group.category}</SubTrigger>
<DropdownMenuSubContent className="z-[600]"> <SubContent className="z-[600]">
{group.agents.map((agent) => ( {group.agents.map((agent) => (
<DropdownMenuItem <Item key={agent.dirName} onClick={() => onRunAgent(agent, entry)} className="cursor-pointer">
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name} {agent.name}
</DropdownMenuItem> </Item>
))} ))}
</DropdownMenuSubContent> </SubContent>
</DropdownMenuSub> </Sub>
)) ))
: flatAgents.map((agent) => ( : flatAgents.map((agent) => (
<DropdownMenuItem <Item key={agent.dirName} onClick={() => onRunAgent(agent, entry)} className="cursor-pointer">
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name} {agent.name}
</DropdownMenuItem> </Item>
))} ))}
</DropdownMenuSubContent> </SubContent>
</DropdownMenuSub> </Sub>
)} )}
{hasActions && <DropdownMenuSeparator />} {hasActions && <Separator />}
<DropdownMenuItem onClick={onCut} className="cursor-pointer"> <Item onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />
Cut Cut
</DropdownMenuItem> </Item>
<DropdownMenuItem onClick={onCopy} className="cursor-pointer"> <Item onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Copy Copy
</DropdownMenuItem> </Item>
{!multiSelected && ( {!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer"> <Item onClick={() => onDuplicate(entry)} className="cursor-pointer">
<CopyPlus className="mr-2 h-4 w-4" />
Duplicate
</Item>
)}
{!multiSelected && (
<Item onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Rename Rename
</DropdownMenuItem> </Item>
)} )}
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer"> {/* Compress, the missing half of Extract. `/download` already shelled out to zip but streamed the
result away; nothing could make an archive and keep it. */}
<Item onClick={() => onCompress(entry)} className="cursor-pointer">
<FileArchive className="mr-2 h-4 w-4" />
{multiSelected ? 'Compress selection' : 'Compress'}
</Item>
<Item onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" /> <ClipboardCopy className="mr-2 h-4 w-4" />
Copy path Copy path
</DropdownMenuItem> </Item>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer"> <Item onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Download Download
</DropdownMenuItem> </Item>
<DropdownMenuSeparator /> <Separator />
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer"> {/* Pinning lived only in the widget, which is not where you meet a file worth keeping to hand. */}
<Item onClick={() => onTogglePin(entry)} className="cursor-pointer">
{isPinned ? <PinOff className="mr-2 h-4 w-4" /> : <Pin className="mr-2 h-4 w-4" />}
{isPinned ? 'Unpin' : 'Pin'}
</Item>
<Item onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
Chat... Chat...
</DropdownMenuItem> </Item>
{entry.type === 'directory' && ( {isDir && (
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer"> <Item onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> <LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here Create Dashboard here
</DropdownMenuItem> </Item>
)} )}
<DropdownMenuSeparator /> <Separator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer"> <Item onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </Item>
</> </>
); );
}; };
const ContextMenuItems = ({ const DROPDOWN_UI = {
entry, Item: DropdownMenuItem,
multiSelected, Separator: DropdownMenuSeparator,
onDelete, Sub: DropdownMenuSub,
onStartRename, SubTrigger: DropdownMenuSubTrigger,
onChat, SubContent: DropdownMenuSubContent,
onCopyPath, } as unknown as MenuPrimitives;
onDownload,
onReadAloud,
onExtract,
onCut,
onCopy,
taskGroups,
onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showExtract = fileType === 'archive';
const hasTasks = taskGroups.length > 0; const CONTEXT_UI = {
const hasAgents = agentGroups.length > 0; Item: ContextMenuItem,
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents; Separator: ContextMenuSeparator,
// Only nest when more than one category matched — a file usually matches a single category, and Sub: ContextMenuSub,
// Run Task > Video > Convert would just add a hop. SubTrigger: ContextMenuSubTrigger,
const nestTasks = taskGroups.length > 1; SubContent: ContextMenuSubContent,
const flatTasks = taskGroups[0]?.tasks ?? []; } as unknown as MenuPrimitives;
const nestAgents = agentGroups.length > 1;
const flatAgents = agentGroups[0]?.agents ?? [];
return ( const DropdownMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={DROPDOWN_UI} />;
<> const ContextMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={CONTEXT_UI} />;
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</ContextMenuItem>
)}
{hasTasks && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestTasks
? taskGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.tasks.map((task) => (
<ContextMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: flatTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasAgents && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestAgents
? agentGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.agents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: flatAgents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasActions && <ContextMenuSeparator />}
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
{entry.type === 'directory' && (
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</>
);
};
const EllipsisMenu = (props: MenuItemsProps) => ( const EllipsisMenu = (props: MenuItemsProps) => (
<div onClick={(ev) => ev.stopPropagation()}> <div onClick={(ev) => ev.stopPropagation()}>
@@ -549,6 +485,13 @@ export const FileItem = ({
agentGroups, agentGroups,
onRunAgent, onRunAgent,
onCreateDashboard, onCreateDashboard,
onOpenInEditor,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
isPinned,
canPaste,
}: FileItemProps) => { }: FileItemProps) => {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -632,6 +575,14 @@ export const FileItem = ({
agentGroups, agentGroups,
onRunAgent, onRunAgent,
onCreateDashboard, onCreateDashboard,
onOpen,
onOpenInEditor,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
isPinned,
canPaste,
}; };
const cutOpacity = isCut ? 'opacity-50' : ''; const cutOpacity = isCut ? 'opacity-50' : '';
@@ -1,7 +1,28 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react'; import {
Loader2,
Folder,
ClipboardPaste,
FolderPlus,
FolderUp,
LayoutGrid,
Upload,
ClipboardCopy,
MessageSquare,
Download,
Mic,
GitBranch,
Eye,
EyeOff,
} from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp'; import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { FileGrid } from './FileGrid'; import { FileGrid } from './FileGrid';
@@ -31,6 +52,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
setShowVideoDownload, setShowVideoDownload,
setShowDictate, setShowDictate,
handleUpload, handleUpload,
handleGitCloneUrl,
showHidden,
setShowHidden,
clipboard,
} = fileBrowserManager; } = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -92,9 +117,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
})} })}
</div> </div>
) : searchResults ? ( ) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm"> <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
No results found
</div>
) : null} ) : null}
</div> </div>
) : ( ) : (
@@ -119,10 +142,15 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
Chat about this Chat about this
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={handlePaste} className="cursor-pointer"> {/* Gated on the clipboard, as both toolbars already were. Ungated it fell through to the
<ClipboardPaste className="mr-2 h-4 w-4" /> OS clipboard path, which on an insecure origin toasts an error and otherwise silently
Paste did nothing — an item that looks available and is not. */}
</ContextMenuItem> {clipboard !== null && (
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
)}
<ContextMenuItem <ContextMenuItem
onClick={() => { onClick={() => {
const name = prompt('Folder name'); const name = prompt('Folder name');
@@ -141,6 +169,24 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<FolderUp className="mr-2 h-4 w-4" /> <FolderUp className="mr-2 h-4 w-4" />
Upload folder Upload folder
</ContextMenuItem> </ContextMenuItem>
{/* Was toolbar-only, and the toolbar hides it under `md:` — so on a phone there was no way
to clone at all. */}
<ContextMenuItem
onClick={() => {
const url = prompt('Repository URL');
if (url?.trim()) handleGitCloneUrl(url.trim());
}}
className="cursor-pointer"
>
<GitBranch className="mr-2 h-4 w-4" />
Clone repository
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setShowHidden(!showHidden)} className="cursor-pointer">
{showHidden ? <EyeOff className="mr-2 h-4 w-4" /> : <Eye className="mr-2 h-4 w-4" />}
{showHidden ? 'Hide hidden files' : 'Show hidden files'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer"> <ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> <LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here Create Dashboard here
@@ -6,8 +6,16 @@ type SelectionActionsProps = {
}; };
export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => { export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => {
const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } = const {
fileBrowserManager; selected,
clipboard,
handleCut,
handleCopy,
handlePaste,
handleDownloadSelected,
handleDeleteSelected,
setSelected,
} = fileBrowserManager;
return ( return (
<> <>
@@ -33,19 +33,61 @@ type ParallelStep = {
type ServerMessage = type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] } | { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } } | {
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } jobId: string;
type: 'step:start';
stepIndex: number;
taskName: string;
iteration?: { current: number; total: number; label: string };
}
| {
jobId: string;
type: 'step:complete';
stepIndex: number;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string } | { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number } | {
jobId: string;
type: 'step:parallel';
stepIndex: number;
taskName: string;
iterations: string[];
concurrency: number;
}
| { jobId: string; type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number } | { jobId: string; type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number }
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string } | { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } | {
jobId: string;
type: 'iteration:complete';
stepIndex: number;
label: string;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string } | { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string } | { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string } | { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; iterationLabel?: string } | {
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; iterationLabel?: string } jobId: string;
| { jobId: string; type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } } type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
iterationLabel?: string;
}
| {
jobId: string;
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
iterationLabel?: string;
}
| {
jobId: string;
type: 'pipeline:complete';
totalCost: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'error'; message: string } | { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' } | { jobId: string; type: 'stopped' }
| { type: 'job:created'; jobId: string } | { type: 'job:created'; jobId: string }
@@ -61,12 +103,18 @@ export function usePipelineRunner() {
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null); const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState(''); const [streamingText, setStreamingText] = useState('');
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null); const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(
null,
);
const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]); const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [waitingStatus, setWaitingStatus] = useState<{ stepIndex: number; elapsed: number; iterationLabel?: string } | null>(null); const [waitingStatus, setWaitingStatus] = useState<{
stepIndex: number;
elapsed: number;
iterationLabel?: string;
} | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const startTimeRef = useRef<number>(0); const startTimeRef = useRef<number>(0);
@@ -84,7 +132,10 @@ export function usePipelineRunner() {
}, []); }, []);
const stopTimer = useCallback(() => { const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []); }, []);
const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => { const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => {
@@ -95,178 +146,184 @@ export function usePipelineRunner() {
})); }));
}, []); }, []);
const handleEvent = useCallback((msg: ServerMessage) => { const handleEvent = useCallback(
// Filter events by jobId (ignore events from other jobs) (msg: ServerMessage) => {
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return; // Filter events by jobId (ignore events from other jobs)
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return;
switch (msg.type) { switch (msg.type) {
case 'job:created': case 'job:created':
jobIdRef.current = msg.jobId; jobIdRef.current = msg.jobId;
setJobId(msg.jobId); setJobId(msg.jobId);
break; break;
case 'job:state': case 'job:state':
// Reconnection to a completed/failed job // Reconnection to a completed/failed job
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') { if (
msg.status === 'completed' ||
msg.status === 'failed' ||
msg.status === 'stopped' ||
msg.status === 'interrupted'
) {
setPhase('done');
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
}
break;
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'step:start':
flushStream();
setMessages([]);
setParallelStep(null);
setWaitingStatus(null);
inParallelRef.current = false;
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setWaitingStatus(null);
setCurrentStep((prev) => (prev ? { ...prev, status: 'complete', cost: msg.cost } : null));
if (msg.cost) addCost(msg.cost);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:waiting':
setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel });
break;
case 'step:parallel':
flushStream();
setMessages([]);
setCurrentStep(null);
inParallelRef.current = true;
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
break;
case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
}
case 'tool:start':
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: randomId(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
if (inParallelRef.current && msg.iterationLabel) break;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done'); setPhase('done');
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer(); stopTimer();
} break;
break;
case 'pipeline:init': case 'error':
setSteps(msg.steps); flushStream();
break; setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
break;
case 'step:start': case 'stopped':
flushStream(); flushStream();
setMessages([]); setPhase('done');
setParallelStep(null); stopTimer();
setWaitingStatus(null); break;
inParallelRef.current = false;
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setWaitingStatus(null);
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
if (msg.cost) addCost(msg.cost);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:waiting':
setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel });
break;
case 'step:parallel':
flushStream();
setMessages([]);
setCurrentStep(null);
inParallelRef.current = true;
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
break;
case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
} }
},
case 'tool:start': [flushStream, stopTimer, addCost],
if (inParallelRef.current && msg.iterationLabel) break; );
setWaitingStatus(null);
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: randomId(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
if (inParallelRef.current && msg.iterationLabel) break;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
stopTimer();
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
break;
case 'stopped':
flushStream();
setPhase('done');
stopTimer();
break;
}
}, [flushStream, stopTimer, addCost]);
// The socket below is opened once and must stay open, so its listener is registered once too — and would // The socket below is opened once and must stay open, so its listener is registered once too — and would
// hold the first render's `handleEvent` forever. That closure carries `flushStream`'s captured // hold the first render's `handleEvent` forever. That closure carries `flushStream`'s captured
@@ -308,32 +365,35 @@ export function usePipelineRunner() {
}; };
}, []); }, []);
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => { const run = useCallback(
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; (taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setPhase('running'); setPhase('running');
setMessages([]); setMessages([]);
setStreamingText(''); setStreamingText('');
setTotalCost(null); setTotalCost(null);
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
setHasError(false); setHasError(false);
setSkippedItems([]); setSkippedItems([]);
setCurrentStep(null); setCurrentStep(null);
setParallelStep(null); setParallelStep(null);
setWaitingStatus(null); setWaitingStatus(null);
setElapsed(0); setElapsed(0);
setJobId(null); setJobId(null);
jobIdRef.current = null; jobIdRef.current = null;
streamBufferRef.current = ''; streamBufferRef.current = '';
startTimeRef.current = Date.now(); startTimeRef.current = Date.now();
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000); }, 1000);
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt })); wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt }));
}, []); },
[],
);
const stop = useCallback(() => { const stop = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return; if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return;
@@ -341,7 +401,21 @@ export function usePipelineRunner() {
}, []); }, []);
return { return {
phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText, phase,
totalCost, runningCost, hasError, skippedItems, elapsed, waitingStatus, run, stop, isConnected,
jobId,
steps,
currentStep,
parallelStep,
messages,
streamingText,
totalCost,
runningCost,
hasError,
skippedItems,
elapsed,
waitingStatus,
run,
stop,
}; };
} }
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router'; import { useSearchParams, useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI'; import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { usePinnedFiles } from '../usePinnedFiles';
import { useTasks, type TaskSummary } from '../useTasks'; import { useTasks, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents'; import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
@@ -65,6 +66,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid'); const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false); const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
// Pinning existed only in the widget. The browser is where you meet a file you want to keep to hand.
const { togglePin, isPinned } = usePinnedFiles();
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [clipboard, setClipboard] = useState<ClipboardState>(null); const [clipboard, setClipboard] = useState<ClipboardState>(null);
@@ -500,6 +503,90 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
} }
}; };
/**
* Copy one item next to itself. The server resolves the collision, so this is `copy` into the folder
* it already lives in — `report.pdf` becomes `report (copy).pdf` with no name to invent here.
*
* Deliberately single-entry: it acts on the clicked row, not the selection, because "duplicate these
* six things" is a batch operation whose failure mode (four succeeded) needs reporting this does not do.
*/
const handleDuplicate = async (entry: DirEntry) => {
try {
await files.copy([entryPath(entry.name)], currentPath);
await refresh();
toast.success(`Duplicated "${entry.name}"`);
} catch {
toast.error('Failed to duplicate');
}
};
/** Zip the selection (or the clicked entry) into an archive beside it, and keep it. */
const handleCompress = async (entry: DirEntry) => {
const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
const toastId = toast.loading('Compressing...');
try {
const { name } = await files.compress(paths);
await refresh();
toast.success(`Created "${name}"`, { id: toastId });
} catch {
toast.error('Failed to compress', { id: toastId });
}
};
/**
* Paste INTO a folder rather than into the current one.
*
* The background menu's paste targets `currentPath`, which meant a move was only expressible as
* cut → open the folder → paste. Right-clicking the destination is the obvious gesture and it did
* not exist.
*/
const handlePasteInto = async (entry: DirEntry) => {
if (!clipboard || entry.type !== 'directory') return;
const destination = entryPath(entry.name);
try {
if (clipboard.mode === 'copy') await files.copy(clipboard.paths, destination);
else {
await files.move(clipboard.paths, destination);
setClipboard(null);
}
await refresh();
toast.success(`Pasted into "${entry.name}"`);
} catch {
toast.error('Failed to paste');
}
};
/** Clone straight into the browsed folder, without the toolbar's inline input. */
const handleGitCloneUrl = async (url: string) => {
const trimmed = url.trim();
if (!trimmed) return;
const toastId = toast.loading('Cloning...');
try {
await files.gitClone(trimmed, currentPath);
await refresh();
toast.success('Repository cloned', { id: toastId });
} catch {
toast.error('Failed to clone repository', { id: toastId });
}
};
/**
* Open a text file in the code editor rather than the viewer.
*
* `/code-editor` is on the SAME `files` permission as `/files` (registry.ts), so anyone who can browse
* can edit — there was simply no way to get there from a file. `?file=` is the editor's own param.
*/
const handleOpenInEditor = (entry: DirEntry) => {
navigate(`/code-editor?file=${encodeURIComponent(entryPath(entry.name))}`);
};
const handleTogglePin = (entry: DirEntry) => {
const path = entryPath(entry.name);
const wasPinned = isPinned(path);
togglePin(path, entry.name);
toast.success(wasPinned ? `Unpinned "${entry.name}"` : `Pinned "${entry.name}"`);
};
const handleCut = () => { const handleCut = () => {
const paths = selected.size > 0 ? selectedPaths() : []; const paths = selected.size > 0 ? selectedPaths() : [];
if (paths.length === 0) return; if (paths.length === 0) return;
@@ -736,6 +823,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
handleCut, handleCut,
handleCopy, handleCopy,
handlePaste, handlePaste,
handleDuplicate,
handleOpenInEditor,
handleTogglePin,
handleCompress,
handlePasteInto,
handleGitCloneUrl,
togglePin,
isPinned,
handleDragEnter, handleDragEnter,
handleDragLeave, handleDragLeave,
handleDragOver, handleDragOver,
@@ -1,41 +0,0 @@
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
};
}
process(_inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
// Deinterleave: buffer is interleaved L R L R ...
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
// Not enough data — output silence
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
@@ -46,6 +46,10 @@ export const useFilesAPI = (root: string = 'home') => {
})), })),
}), }),
/** Zip items into an archive beside them. Unlike `download`, this keeps the result on disk. */
compress: (paths: string[]) =>
client.post<{ ok: boolean; path: string; name: string }>(withRoot('/file-browser/compress'), { paths }),
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }), gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
downloadVideo: (url: string, path: string, audioOnly: boolean) => downloadVideo: (url: string, path: string, audioOnly: boolean) =>