diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 9b7ff49d..a8a3f4fb 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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 `.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 router.delete('/rm', async (ctx) => { const user = ctx.get('user'); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 24761ce0..8db09e1a 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -71,6 +71,12 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { handleRunTask, handleRunAgent, handleCreateDashboard, + handleOpenInEditor, + handleCompress, + handleDuplicate, + handlePasteInto, + handleTogglePin, + isPinned, fileScrollRef: scrollRef, setSearchQuery, searchInputRef, @@ -248,6 +254,13 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { agentGroups={getMatchingAgentGroups(entry.name, entry.type)} onRunAgent={handleRunAgent} onCreateDashboard={handleCreateDashboard} + onOpenInEditor={handleOpenInEditor} + onCompress={handleCompress} + onDuplicate={handleDuplicate} + onPasteInto={handlePasteInto} + onTogglePin={handleTogglePin} + isPinned={isPinned(entryPath(entry.name))} + canPaste={clipboard !== null} /> ); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 3c7cc116..39fbb529 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -14,7 +14,15 @@ import { Volume2, FolderArchive, ClipboardCopy, + ClipboardPaste, Bot, + FolderOpen, + ExternalLink, + Code, + CopyPlus, + FileArchive, + Pin, + PinOff, } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { @@ -68,6 +76,13 @@ export type FileItemProps = { agentGroups: AgentGroup[]; onRunAgent: (agent: AgentSummary, 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 { @@ -84,9 +99,21 @@ function formatDate(ms: number): string { }); } +type MenuPrimitives = { + Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>; + Separator: React.ComponentType>; + Sub: React.ComponentType<{ children: React.ReactNode }>; + SubTrigger: React.ComponentType<{ className?: string; children: React.ReactNode }>; + SubContent: React.ComponentType<{ className?: string; children: React.ReactNode }>; +}; + type MenuItemsProps = { entry: DirEntry; multiSelected: boolean; + isPinned: boolean; + canPaste: boolean; + onOpen: (e: DirEntry) => void; + onOpenInEditor: (e: DirEntry) => void; onDelete: (e: DirEntry) => void; onStartRename: () => void; onChat: (e: DirEntry) => void; @@ -94,6 +121,10 @@ type MenuItemsProps = { onDownload: (e: DirEntry) => void; onReadAloud: (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; onCopy: () => void; taskGroups: TaskGroup[]; @@ -103,9 +134,21 @@ type MenuItemsProps = { 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, multiSelected, + isPinned, + canPaste, + onOpen, + onOpenInEditor, onDelete, onStartRename, onChat, @@ -113,6 +156,10 @@ const DropdownMenuItems = ({ onDownload, onReadAloud, onExtract, + onCompress, + onDuplicate, + onPasteInto, + onTogglePin, onCut, onCopy, taskGroups, @@ -120,10 +167,15 @@ const DropdownMenuItems = ({ agentGroups, onRunAgent, onCreateDashboard, -}: MenuItemsProps) => { +}: MenuItemsProps & { ui: MenuPrimitives }) => { + const isDir = entry.type === 'directory'; const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; 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 hasAgents = agentGroups.length > 0; @@ -137,289 +189,173 @@ const DropdownMenuItems = ({ return ( <> + {/* Opening was double-click only, with no menu entry at all — the one action every file manager + puts first. */} + onOpen(entry)} className="cursor-pointer"> + {isDir ? : } + Open + + {showEdit && ( + onOpenInEditor(entry)} className="cursor-pointer"> + + Open in editor + + )} + {/* 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 && ( + onPasteInto(entry)} className="cursor-pointer"> + + Paste into folder + + )} + {showReadAloud && ( - onReadAloud(entry)} className="cursor-pointer"> + onReadAloud(entry)} className="cursor-pointer"> Read Aloud - + )} {showExtract && ( - onExtract(entry)} className="cursor-pointer"> + onExtract(entry)} className="cursor-pointer"> Extract - + )} {hasTasks && ( - - + + Run Task - - + + {nestTasks ? taskGroups.map((group) => ( - - {group.category} - + + {group.category} + {group.tasks.map((task) => ( - onRunTask(task, entry)} - className="cursor-pointer" - > + onRunTask(task, entry)} className="cursor-pointer"> {task.name} - + ))} - - + + )) : flatTasks.map((task) => ( - onRunTask(task, entry)} - className="cursor-pointer" - > + onRunTask(task, entry)} className="cursor-pointer"> {task.name} - + ))} - - + + )} {hasAgents && ( - - + + Run Agent - - + + {nestAgents ? agentGroups.map((group) => ( - - {group.category} - + + {group.category} + {group.agents.map((agent) => ( - onRunAgent(agent, entry)} - className="cursor-pointer" - > + onRunAgent(agent, entry)} className="cursor-pointer"> {agent.name} - + ))} - - + + )) : flatAgents.map((agent) => ( - onRunAgent(agent, entry)} - className="cursor-pointer" - > + onRunAgent(agent, entry)} className="cursor-pointer"> {agent.name} - + ))} - - + + )} - {hasActions && } - + {hasActions && } + Cut - - + + Copy - + {!multiSelected && ( - + onDuplicate(entry)} className="cursor-pointer"> + + Duplicate + + )} + {!multiSelected && ( + Rename - + )} - 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. */} + onCompress(entry)} className="cursor-pointer"> + + {multiSelected ? 'Compress selection' : 'Compress'} + + onCopyPath(entry)} className="cursor-pointer"> Copy path - - onDownload(entry)} className="cursor-pointer"> + + onDownload(entry)} className="cursor-pointer"> Download - - - onChat(entry)} className="cursor-pointer"> + + + {/* Pinning lived only in the widget, which is not where you meet a file worth keeping to hand. */} + onTogglePin(entry)} className="cursor-pointer"> + {isPinned ? : } + {isPinned ? 'Unpin' : 'Pin'} + + onChat(entry)} className="cursor-pointer"> Chat... - - {entry.type === 'directory' && ( - onCreateDashboard(entry)} className="cursor-pointer"> + + {isDir && ( + onCreateDashboard(entry)} className="cursor-pointer"> Create Dashboard here - + )} - - onDelete(entry)} className="text-red-600 cursor-pointer"> + + onDelete(entry)} className="text-red-600 cursor-pointer"> Delete - + ); }; -const ContextMenuItems = ({ - entry, - multiSelected, - onDelete, - onStartRename, - onChat, - onCopyPath, - 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 DROPDOWN_UI = { + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, + Sub: DropdownMenuSub, + SubTrigger: DropdownMenuSubTrigger, + SubContent: DropdownMenuSubContent, +} as unknown as MenuPrimitives; - const hasTasks = taskGroups.length > 0; - const hasAgents = agentGroups.length > 0; - const hasActions = showReadAloud || showExtract || hasTasks || hasAgents; - // Only nest when more than one category matched — a file usually matches a single category, and - // Run Task > Video > Convert would just add a hop. - const nestTasks = taskGroups.length > 1; - const flatTasks = taskGroups[0]?.tasks ?? []; - const nestAgents = agentGroups.length > 1; - const flatAgents = agentGroups[0]?.agents ?? []; +const CONTEXT_UI = { + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +} as unknown as MenuPrimitives; - return ( - <> - {showReadAloud && ( - onReadAloud(entry)} className="cursor-pointer"> - - Read Aloud - - )} - {showExtract && ( - onExtract(entry)} className="cursor-pointer"> - - Extract - - )} - {hasTasks && ( - - - - Run Task - - - {nestTasks - ? taskGroups.map((group) => ( - - {group.category} - - {group.tasks.map((task) => ( - onRunTask(task, entry)} - className="cursor-pointer" - > - {task.name} - - ))} - - - )) - : flatTasks.map((task) => ( - onRunTask(task, entry)} className="cursor-pointer"> - {task.name} - - ))} - - - )} - {hasAgents && ( - - - - Run Agent - - - {nestAgents - ? agentGroups.map((group) => ( - - {group.category} - - {group.agents.map((agent) => ( - onRunAgent(agent, entry)} - className="cursor-pointer" - > - {agent.name} - - ))} - - - )) - : flatAgents.map((agent) => ( - onRunAgent(agent, entry)} - className="cursor-pointer" - > - {agent.name} - - ))} - - - )} - {hasActions && } - - - Cut - - - - Copy - - {!multiSelected && ( - - - Rename - - )} - onCopyPath(entry)} className="cursor-pointer"> - - Copy path - - onDownload(entry)} className="cursor-pointer"> - - Download - - - onChat(entry)} className="cursor-pointer"> - - Chat... - - {entry.type === 'directory' && ( - onCreateDashboard(entry)} className="cursor-pointer"> - - Create Dashboard here - - )} - - onDelete(entry)} className="text-red-600 cursor-pointer"> - - Delete - - - ); -}; +const DropdownMenuItems = (props: MenuItemsProps) => ; +const ContextMenuItems = (props: MenuItemsProps) => ; const EllipsisMenu = (props: MenuItemsProps) => (
ev.stopPropagation()}> @@ -549,6 +485,13 @@ export const FileItem = ({ agentGroups, onRunAgent, onCreateDashboard, + onOpenInEditor, + onCompress, + onDuplicate, + onPasteInto, + onTogglePin, + isPinned, + canPaste, }: FileItemProps) => { const [renaming, setRenaming] = useState(false); const clickTimer = useRef | null>(null); @@ -632,6 +575,14 @@ export const FileItem = ({ agentGroups, onRunAgent, onCreateDashboard, + onOpen, + onOpenInEditor, + onCompress, + onDuplicate, + onPasteInto, + onTogglePin, + isPinned, + canPaste, }; const cutOpacity = isCut ? 'opacity-50' : ''; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index ec965e77..e84a9552 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,7 +1,28 @@ 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 { 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 { FileGrid } from './FileGrid'; @@ -31,6 +52,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps setShowVideoDownload, setShowDictate, handleUpload, + handleGitCloneUrl, + showHidden, + setShowHidden, + clipboard, } = fileBrowserManager; const fileInputRef = useRef(null); @@ -92,9 +117,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps })}
) : searchResults ? ( -
- No results found -
+
No results found
) : null} ) : ( @@ -119,10 +142,15 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Chat about this - - - Paste - + {/* Gated on the clipboard, as both toolbars already were. Ungated it fell through to the + OS clipboard path, which on an insecure origin toasts an error and otherwise silently + did nothing — an item that looks available and is not. */} + {clipboard !== null && ( + + + Paste + + )} { const name = prompt('Folder name'); @@ -141,6 +169,24 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Upload folder + {/* Was toolbar-only, and the toolbar hides it under `md:` — so on a phone there was no way + to clone at all. */} + { + const url = prompt('Repository URL'); + if (url?.trim()) handleGitCloneUrl(url.trim()); + }} + className="cursor-pointer" + > + + Clone repository + + + setShowHidden(!showHidden)} className="cursor-pointer"> + {showHidden ? : } + {showHidden ? 'Hide hidden files' : 'Show hidden files'} + + Create Dashboard here diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx index ac6415c9..b6308148 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx @@ -6,8 +6,16 @@ type SelectionActionsProps = { }; export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => { - const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } = - fileBrowserManager; + const { + selected, + clipboard, + handleCut, + handleCopy, + handlePaste, + handleDownloadSelected, + handleDeleteSelected, + setSelected, + } = fileBrowserManager; return ( <> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts index 63e4244d..f2552d17 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts @@ -33,19 +33,61 @@ type ParallelStep = { type ServerMessage = | { 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: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: '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: 'assistant:delta'; text: string; iterationLabel?: string } | { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string } - | { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record; 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: 'tool:start'; + toolCallId: string; + toolName: string; + toolInput: Record; + 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: 'stopped' } | { type: 'job:created'; jobId: string } @@ -61,12 +103,18 @@ export function usePipelineRunner() { const [parallelStep, setParallelStep] = useState(null); const [messages, setMessages] = 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 [hasError, setHasError] = useState(false); const [skippedItems, setSkippedItems] = useState>([]); 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(null); const streamBufferRef = useRef(''); const startTimeRef = useRef(0); @@ -84,7 +132,10 @@ export function usePipelineRunner() { }, []); 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 }) => { @@ -95,178 +146,184 @@ export function usePipelineRunner() { })); }, []); - const handleEvent = useCallback((msg: ServerMessage) => { - // Filter events by jobId (ignore events from other jobs) - if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return; + const handleEvent = useCallback( + (msg: ServerMessage) => { + // 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) { - case 'job:created': - jobIdRef.current = msg.jobId; - setJobId(msg.jobId); - break; + switch (msg.type) { + case 'job:created': + jobIdRef.current = msg.jobId; + setJobId(msg.jobId); + break; - case 'job:state': - // Reconnection to a completed/failed job - if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') { + case 'job:state': + // Reconnection to a completed/failed job + 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'); - if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number }); - if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); stopTimer(); - } - break; + break; - case 'pipeline:init': - setSteps(msg.steps); - break; + case 'error': + flushStream(); + setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]); + setHasError(true); + setPhase('done'); + stopTimer(); + 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 'stopped': + flushStream(); + setPhase('done'); + stopTimer(); + 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'); - 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]); + }, + [flushStream, stopTimer, addCost], + ); // 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 @@ -308,32 +365,35 @@ export function usePipelineRunner() { }; }, []); - const run = useCallback((taskDirName: string, inputs: Record, cwd?: string, model?: string, startAt?: number) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + const run = useCallback( + (taskDirName: string, inputs: Record, cwd?: string, model?: string, startAt?: number) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; - setPhase('running'); - setMessages([]); - setStreamingText(''); - setTotalCost(null); - setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); - setHasError(false); - setSkippedItems([]); - setCurrentStep(null); - setParallelStep(null); - setWaitingStatus(null); - setElapsed(0); - setJobId(null); - jobIdRef.current = null; - streamBufferRef.current = ''; + setPhase('running'); + setMessages([]); + setStreamingText(''); + setTotalCost(null); + setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); + setHasError(false); + setSkippedItems([]); + setCurrentStep(null); + setParallelStep(null); + setWaitingStatus(null); + setElapsed(0); + setJobId(null); + jobIdRef.current = null; + streamBufferRef.current = ''; - startTimeRef.current = Date.now(); - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); - }, 1000); + startTimeRef.current = Date.now(); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTimeRef.current) / 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(() => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return; @@ -341,7 +401,21 @@ export function usePipelineRunner() { }, []); return { - phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText, - totalCost, runningCost, hasError, skippedItems, elapsed, waitingStatus, run, stop, + phase, + isConnected, + jobId, + steps, + currentStep, + parallelStep, + messages, + streamingText, + totalCost, + runningCost, + hasError, + skippedItems, + elapsed, + waitingStatus, + run, + stop, }; } diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index c9ad8a39..4fdb8c55 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useSearchParams, useNavigate } from 'react-router'; import { toast } from 'sonner'; import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI'; +import { usePinnedFiles } from '../usePinnedFiles'; import { useTasks, type TaskSummary } from '../useTasks'; import { useAgents, type AgentSummary } from '../useAgents'; import { useUserState } from 'state/useUserState'; @@ -65,6 +66,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid'); const [showHidden, setShowHidden] = useUserState('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(null); const [selected, setSelected] = useState>(new Set()); const [clipboard, setClipboard] = useState(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 paths = selected.size > 0 ? selectedPaths() : []; if (paths.length === 0) return; @@ -736,6 +823,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa handleCut, handleCopy, handlePaste, + handleDuplicate, + handleOpenInEditor, + handleTogglePin, + handleCompress, + handlePasteInto, + handleGitCloneUrl, + togglePin, + isPinned, handleDragEnter, handleDragLeave, handleDragOver, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/pcm-worklet-processor.js b/src/workspaces/officerdev/src/apps/FileBrowser/pcm-worklet-processor.js deleted file mode 100644 index db02aa85..00000000 --- a/src/workspaces/officerdev/src/apps/FileBrowser/pcm-worklet-processor.js +++ /dev/null @@ -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); diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index a5f9ea03..67bc8717 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -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 }), downloadVideo: (url: string, path: string, audioOnly: boolean) =>