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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+177
-226
@@ -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' : '';
|
||||||
|
|||||||
+55
-9
@@ -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
|
||||||
|
|||||||
+10
-2
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+274
-200
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user