major apps and hooks refactor

This commit is contained in:
2026-02-21 21:24:31 +00:00
parent e275c0fea6
commit 9e9acd9631
125 changed files with 3840 additions and 3285 deletions
+4 -6
View File
@@ -48,7 +48,6 @@
"@uiw/react-textarea-code-editor": "^3.1.1", "@uiw/react-textarea-code-editor": "^3.1.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"apps": "workspace:*",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",
"check-password-strength": "^3.0.0", "check-password-strength": "^3.0.0",
@@ -146,9 +145,6 @@
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
}, },
}, },
"src/workspaces/apps": {
"name": "apps",
},
"src/workspaces/components": { "src/workspaces/components": {
"name": "components", "name": "components",
"version": "0.0.1", "version": "0.0.1",
@@ -247,6 +243,10 @@
"src/workspaces/officerdev": { "src/workspaces/officerdev": {
"name": "officerdev", "name": "officerdev",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"hooks": "workspace:*",
"widgets": "workspace:*",
},
}, },
"src/workspaces/sounds": { "src/workspaces/sounds": {
"name": "sounds", "name": "sounds",
@@ -1061,8 +1061,6 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"apps": ["apps@workspace:src/workspaces/apps"],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="], "argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
-1
View File
@@ -124,7 +124,6 @@
"three": "^0.182.0", "three": "^0.182.0",
"types": "workspace:*", "types": "workspace:*",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"apps": "workspace:*",
"officerdev": "workspace:*", "officerdev": "workspace:*",
"widgets": "workspace:*", "widgets": "workspace:*",
"ws": "^8.18.1", "ws": "^8.18.1",
@@ -13,7 +13,7 @@ import { Card } from '@/components/Card';
import { FrontmatterBlock } from '../CapabilityPage'; import { FrontmatterBlock } from '../CapabilityPage';
import type { CapabilityDetail } from '../CapabilityPage'; import type { CapabilityDetail } from '../CapabilityPage';
import type { AutomationSelection } from './AutomationRightPanel'; import type { AutomationSelection } from './AutomationRightPanel';
import { TaskRunnerModal } from 'apps/FileBrowser'; import { TaskRunnerModal } from 'officerdev';
type SelectOption = { value: string; label: string }; type SelectOption = { value: string; label: string };
@@ -8,9 +8,8 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useVisiblePiModels } from '@/state/useModels';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { usePi, EmbeddableChat } from 'apps/Chat'; import { usePiChat, EmbeddableChat } from 'officerdev';
type CapabilitySummary = { type CapabilitySummary = {
dirName: string; dirName: string;
name: string; name: string;
@@ -63,14 +62,13 @@ export const CapabilityChat = ({
description, description,
onResponseEnd, onResponseEnd,
}: CapabilityChatProps) => { }: CapabilityChatProps) => {
const piModels = useVisiblePiModels();
const seedFile = `${kind.toUpperCase()}.md`; const seedFile = `${kind.toUpperCase()}.md`;
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`; const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const defaultInput = isNew const defaultInput = isNew
? description ?? `Help me create the content for this new ${kind} file` ? description ?? `Help me create the content for this new ${kind} file`
: `Help me understand and improve this ${kind} file`; : `Help me understand and improve this ${kind} file`;
const pi = usePi(undefined, undefined, { replaceUrl: false }); const pi = usePiChat(undefined, undefined, { replaceUrl: false });
const onResponseEndRef = useRef(onResponseEnd); const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd; onResponseEndRef.current = onResponseEnd;
@@ -86,7 +84,6 @@ export const CapabilityChat = ({
return ( return (
<EmbeddableChat <EmbeddableChat
chat={pi} chat={pi}
availableModels={piModels}
defaultInput={defaultInput} defaultInput={defaultInput}
promptPrefix={promptFrontmatter} promptPrefix={promptFrontmatter}
className="h-full" className="h-full"
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions'; import { useChatSessions } from '@/state/useChatSessions';
import { usePi, EmbeddableChat } from 'apps/Chat'; import { usePiChat, EmbeddableChat } from 'officerdev';
export type SelectedSession = { export type SelectedSession = {
id: string; id: string;
@@ -70,7 +70,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
// We need connection status for the DetailBar, so we still call usePi here // We need connection status for the DetailBar, so we still call usePi here
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it // TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
const chat = usePi(sessionId, model, { replaceUrl: false }); const chat = usePiChat(sessionId, model, { replaceUrl: false });
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -99,7 +99,7 @@ function NewChat() {
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null); const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
// We need connection status for DetailBar, so call usePi // We need connection status for DetailBar, so call usePi
const chat = usePi(); const chat = usePiChat();
useEffect(() => { useEffect(() => {
if (chat.sessionId) { if (chat.sessionId) {
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { MoreVertical, Edit2, Trash2 } from 'lucide-react'; import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from '@/state/useChatGroups'; import { useChatGroups } from '@/state/useChatGroups';
import type { GroupEntry } from 'apps/Chat'; import type { GroupEntry } from 'officerdev';
type GroupContextMenuProps = { type GroupContextMenuProps = {
group: GroupEntry; group: GroupEntry;
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react'; import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from '@/state/useChatGroups'; import { useChatGroups } from '@/state/useChatGroups';
import { useChatSessions } from '@/state/useChatSessions'; import { useChatSessions } from '@/state/useChatSessions';
import type { SessionEntry } from 'apps/Chat'; import type { SessionEntry } from 'officerdev';
type SessionContextMenuProps = { type SessionContextMenuProps = {
session: SessionEntry; session: SessionEntry;
@@ -1,14 +1,13 @@
import { useRef, useEffect, useState } from 'react'; import { useRef, useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router'; import { useNavigate, useLocation } from 'react-router';
import { useChatSessions } from '@/state/useChatSessions'; import { useChatSessions } from '@/state/useChatSessions';
import { SessionBar } from 'apps/ChatHistory'; import { SessionBar, EmbeddableChat, type UsePiChatType, type Attachment } from 'officerdev';
import { EmbeddableChat, type UsePiType, type Attachment } from 'apps/Chat';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
export type { Attachment }; export type { Attachment };
type ChatPanelProps = { type ChatPanelProps = {
chat: UsePiType; chat: UsePiChatType;
}; };
export const ChatScreen = ({ chat }: ChatPanelProps) => { export const ChatScreen = ({ chat }: ChatPanelProps) => {
@@ -1,4 +1,4 @@
import { CodeEditorView } from 'apps/CodeEditor'; import { CodeEditorView } from 'officerdev';
import { Widget } from 'widgets/Widget'; import { Widget } from 'widgets/Widget';
export const CodeEditor = () => ( export const CodeEditor = () => (
@@ -1,15 +1,17 @@
import type { LayoutNode } from '@/components/Workspace'; import type { LayoutNode } from '@/components/Workspace';
import { WorkspaceView } from '@/components/Workspace'; import { WorkspaceView } from '@/components/Workspace';
import { useWorkspacesState } from '@/state/useWorkspacesState'; import { useWorkspacesState } from '@/state/useWorkspacesState';
import { useFileViewerPanels } from 'officerdev';
import { appRegistry } from '../Workspaces/app-registry'; import { appRegistry } from '../Workspaces/app-registry';
import { defaultLayout } from './defaultLayout'; import { defaultLayout } from './defaultLayout';
export const FilesScreen = () => { export const FilesScreen = () => {
const workspace = useWorkspacesState<LayoutNode>('screens/files', defaultLayout); const workspace = useWorkspacesState<LayoutNode>('screens/files', defaultLayout);
const ephemeral = useFileViewerPanels();
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView workspace={workspace} registry={appRegistry} /> <WorkspaceView workspace={workspace} registry={appRegistry} ephemeral={ephemeral} />
</div> </div>
); );
}; };
@@ -6,8 +6,7 @@ import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { TerminalView } from 'apps/Terminal'; import { TerminalView, FileViewerView } from 'officerdev';
import { FileViewerView } from 'apps/FileViewer';
import { appRegistry } from '../../Workspaces/app-registry'; import { appRegistry } from '../../Workspaces/app-registry';
import { Resources } from './Resources'; import { Resources } from './Resources';
import { ResourceSidebar } from './ResourceSidebar'; import { ResourceSidebar } from './ResourceSidebar';
@@ -11,7 +11,7 @@ import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { TerminalView } from 'apps/Terminal'; import { TerminalView } from 'officerdev';
import { appRegistry } from '../Workspaces/app-registry'; import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings'; import { useSettings } from '@/state/useSettings';
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react'; import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { MessageBubble, type ChatMessage } from 'apps/Chat'; import { MessageBubble, type ChatMessage } from 'officerdev';
type LogMetadata = { type LogMetadata = {
filename: string; filename: string;
@@ -10,7 +10,7 @@ import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/component
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace'; import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { generateSlug, slugify } from 'helpers/slug'; import { generateSlug, slugify } from 'helpers/slug';
import { Files } from 'apps/FileBrowser'; import { FileBrowserApp } from 'officerdev';
import { appRegistry } from './app-registry'; import { appRegistry } from './app-registry';
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants'; import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
@@ -396,7 +396,7 @@ const CreatePanel = () => {
const CwdFileBrowser = () => { const CwdFileBrowser = () => {
const [currentPath] = useUserState<string>('files/currentPath', '/'); const [currentPath] = useUserState<string>('files/currentPath', '/');
const [basePath] = useState(currentPath); const [basePath] = useState(currentPath);
return <Files basePath={basePath} />; return <FileBrowserApp basePath={basePath} />;
}; };
// --- New Workspace Layout --- // --- New Workspace Layout ---
@@ -5,15 +5,11 @@ import { Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Colu
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import type { AppRegistry } from '@/components/Workspace'; import type { AppRegistry } from '@/components/Workspace';
import { useWorkspace } from '@/components/Workspace'; import { useWorkspace } from '@/components/Workspace';
import { CodeEditorView } from 'apps/CodeEditor'; import { CodeEditorView, TerminalView, FileViewerProvider, FileViewerHeader, FileViewerBody, EmbeddableChat, ChatLauncher, usePiChat, FileBrowserApp } from 'officerdev';
import { TerminalView } from 'apps/Terminal';
import { useWorkspacesState } from '@/state/useWorkspacesState'; import { useWorkspacesState } from '@/state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
import { EmbeddableChat, ChatLauncher, usePi } from 'apps/Chat';
import { useVisiblePiModels } from '@/state/useModels'; import { useVisiblePiModels } from '@/state/useModels';
import { ChatHistoryApp as ChatHistory } from '../ChatHistory'; import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
import { Files } from 'apps/FileBrowser';
import { Catalog } from 'sounds'; import { Catalog } from 'sounds';
import { WorkspaceListApp } from './WorkspaceListApp'; import { WorkspaceListApp } from './WorkspaceListApp';
import { ProjectListApp } from '../Projects/ProjectListApp'; import { ProjectListApp } from '../Projects/ProjectListApp';
@@ -23,7 +19,7 @@ import { WidgetPanel } from 'widgets/WidgetPanel';
const ChatWidget = () => <EmbeddableChat className="h-full" />; const ChatWidget = () => <EmbeddableChat className="h-full" />;
const ChatLauncherWidget = () => { const ChatLauncherWidget = () => {
const pi = usePi(); const pi = usePiChat();
const models = useVisiblePiModels(); const models = useVisiblePiModels();
console.log(models) console.log(models)
return ( return (
@@ -41,7 +37,7 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
const FileBrowserWrapper = () => { const FileBrowserWrapper = () => {
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
const basePath = cwdToPath(cwd); const basePath = cwdToPath(cwd);
return <Files basePath={basePath} />; return <FileBrowserApp basePath={basePath} />;
}; };
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />; const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { GroupEntry } from 'apps/Chat'; import type { GroupEntry } from 'officerdev';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -1,4 +1,4 @@
import type { SessionEntry, ChatMessage, Message } from 'apps/Chat'; import type { SessionEntry, ChatMessage, Message } from 'officerdev';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
+1 -1
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useSettings } from './useSettings'; import { useSettings } from './useSettings';
import type { ModelOption } from 'apps/Chat'; import type { ModelOption } from 'officerdev';
export type { ModelOption }; export type { ModelOption };
-195
View File
@@ -1,195 +0,0 @@
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import type { ModelOption, Attachment } from './types';
import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
type ChatLauncherProps = {
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
onSubmit: (data: {
prompt: string;
model: string | null;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
}) => void;
placeholder?: string;
};
export function ChatLauncher({
availableModels,
selectedModel,
onModelChange,
onSubmit,
placeholder = 'What do you want to work on now?',
}: ChatLauncherProps) {
const client = useClient();
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const handleAttachWebpage = async (url: string) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
]);
try {
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
provider: 'pi-mono',
});
setAttachments((prev) =>
prev.map((a, i) =>
i === idx
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
: a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to scrape webpage');
}
};
const handleAttachImage = async (file: File) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
]);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('provider', 'pi-mono');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
prev.map((a, i) =>
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to upload image');
}
};
const handleSubmit = () => {
const text = input.trim();
if (!text) return;
let prompt = text;
const attachmentIds: string[] = [];
const images: { filename: string; dataUrl: string }[] = [];
for (const a of attachments) {
if (a.loading) continue;
if (a.type === 'webpage' && a.content) {
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
} else if (a.type === 'image' && a.dataUrl) {
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
images.push({ filename: a.filename, dataUrl: a.dataUrl });
}
attachmentIds.push(a.attachmentId);
}
onSubmit({
prompt,
model: selectedModel,
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
images: images.length > 0 ? images : undefined,
});
// Reset form
setInput('');
setAttachments([]);
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSubmit();
}
};
const handleImagePaste = (file: File) => {
handleAttachImage(file);
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
}, [input]);
return (
<div className="p-4 pb-2 pt-1">
<AttachmentList attachments={attachments} onRemove={(i) => setAttachments((prev) => prev.filter((_, j) => j !== i))} />
<div className="flex items-end gap-2">
<AttachButton
size="md"
onAttachImage={handleAttachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) handleImagePaste(file);
return;
}
}
}}
placeholder={placeholder}
rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
/>
<Button
onClick={handleSubmit}
disabled={!input.trim()}
size="icon"
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
</div>
<ModelSelector
messages={[]}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={selectedModel}
isConnected={true}
isGenerating={false}
/>
<WebpageDialog
open={urlDialogOpen}
onOpenChange={setUrlDialogOpen}
onSubmit={handleAttachWebpage}
/>
</div>
);
}
-274
View File
@@ -1,274 +0,0 @@
import type { KeyboardEvent } from 'react';
import { useRef, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useVisiblePiModels } from '@/state/useModels';
import { usePi } from './usePi';
import { MessageList } from './MessageList';
import { InputArea } from './InputArea';
import type { Attachment } from './types';
import { useSlashCommands } from './useSlashCommands';
type EmbeddableChatProps = {
sessionId?: string;
initialModel?: string | null;
initialMessage?: {
text: string;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
cwd?: { root?: string; path: string };
};
defaultInput?: string;
promptPrefix?: string;
className?: string;
cwd?: { root?: string; path: string };
autoSend?: boolean;
};
export const EmbeddableChat = ({
sessionId: initialSessionId,
initialModel,
initialMessage,
defaultInput = '',
promptPrefix,
className,
cwd,
autoSend = false,
}: EmbeddableChatProps) => {
const chat = usePi(initialSessionId, initialModel);
const availableModels = useVisiblePiModels();
const {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel,
sendPrompt,
stopGeneration,
} = chat;
const client = useClient();
const [input, setInput] = useState(defaultInput);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const slashCommandHandler = useSlashCommands({ sessionId });
const handleAttachWebpage = async (url: string) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
]);
try {
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
sessionId: sessionId ?? undefined,
provider: 'pi-mono',
});
setAttachments((prev) =>
prev.map((a, i) =>
i === idx
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
: a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to scrape webpage');
}
};
const handleAttachImage = async (file: File) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
]);
try {
const formData = new FormData();
formData.append('file', file);
if (sessionId) formData.append('sessionId', sessionId);
formData.append('provider', 'pi-mono');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
prev.map((a, i) =>
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to upload image');
}
};
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
const handleSend = async () => {
const text = input.trim();
if (!text || isGenerating) return;
// Handle slash commands
if (text.startsWith('/')) {
const result = await slashCommandHandler.execute(text);
if (result.handled) {
setCommandFeedback(result.feedback);
setInput('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
return;
}
}
setCommandFeedback(null);
// Prepend metadata/attachment content to the prompt
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
const ids: string[] = [];
const images: { filename: string; dataUrl: string }[] = [];
for (const a of attachments) {
if (a.loading) continue;
if (a.type === 'webpage' && a.content) {
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
} else if (a.type === 'image' && a.dataUrl) {
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
images.push({ filename: a.filename, dataUrl: a.dataUrl });
}
ids.push(a.attachmentId);
}
// On first message (no sessionId), include attachmentIds so server can relocate tmp files
const cwdForFirst = !sessionId ? cwd : undefined;
sendPrompt(
prompt,
!sessionId && ids.length > 0 ? ids : undefined,
images.length > 0 ? images : undefined,
cwdForFirst,
);
setAttachments([]);
setInput('');
userScrolledRef.current = false;
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSend();
}
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}, [input]);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (!userScrolledRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, streamingText]);
// Detect user scrolling up
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
userScrolledRef.current = !atBottom;
setShowJumpToBottom(!atBottom);
};
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
}, []);
const jumpToBottom = () => {
userScrolledRef.current = false;
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
};
// Focus textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
// Auto-send initial message when provided
const initialSentRef = useRef(false);
useEffect(() => {
if (initialMessage && isConnected && !initialSentRef.current) {
initialSentRef.current = true;
if (initialModel) setSelectedModel(initialModel);
sendPrompt(
initialMessage.text,
initialMessage.attachmentIds,
initialMessage.images,
initialMessage.cwd,
);
}
}, [initialMessage, isConnected]);
// Auto-send first message when autoSend is enabled
const autoSentRef = useRef(false);
useEffect(() => {
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
autoSentRef.current = true;
handleSend();
}
}, [autoSend, isConnected, messages.length, input]);
return (
<div className={`flex flex-col ${className ?? ''}`}>
<MessageList
messages={messages}
streamingText={streamingText}
isGenerating={isGenerating}
showJumpToBottom={showJumpToBottom}
onJumpToBottom={jumpToBottom}
onQuestionAnswer={(text) => sendPrompt(text)}
scrollViewportRef={scrollViewportRef}
bottomRef={bottomRef}
/>
<InputArea
input={input}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
onStop={stopGeneration}
isGenerating={isGenerating}
isConnected={isConnected}
commandFeedback={commandFeedback}
textareaRef={textareaRef}
messages={messages}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
model={model}
attachments={attachments}
onAttachWebpage={handleAttachWebpage}
onAttachImage={handleAttachImage}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
);
};
-253
View File
@@ -1,253 +0,0 @@
import type { KeyboardEvent, RefObject } from 'react';
import { useState, useRef } from 'react';
import { Loader2, Mic, Send, Square } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage, Attachment } from './types';
import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
const blobToWav = async (blob: Blob): Promise<Blob> => {
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
await ctx.close();
const samples = buf.getChannelData(0);
const len = samples.length;
const sr = buf.sampleRate;
const ab = new ArrayBuffer(44 + len * 2);
const v = new DataView(ab);
const s = (o: number, str: string) => {
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
};
s(0, 'RIFF');
v.setUint32(4, 36 + len * 2, true);
s(8, 'WAVE');
s(12, 'fmt ');
v.setUint32(16, 16, true);
v.setUint16(20, 1, true);
v.setUint16(22, 1, true);
v.setUint32(24, sr, true);
v.setUint32(28, sr * 2, true);
v.setUint16(32, 2, true);
v.setUint16(34, 16, true);
s(36, 'data');
v.setUint32(40, len * 2, true);
for (let i = 0; i < len; i++) {
const val = Math.max(-1, Math.min(1, samples[i]!));
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
}
return new Blob([ab], { type: 'audio/wav' });
};
type InputAreaProps = {
input: string;
onInputChange: (value: string) => void;
onKeyDown: (ev: KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
onStop: () => void;
isGenerating: boolean;
isConnected: boolean;
commandFeedback: string | null;
textareaRef: RefObject<HTMLTextAreaElement | null>;
messages: ChatMessage[];
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
model: string | null;
attachments: Attachment[];
onAttachWebpage: (url: string) => void;
onAttachImage: (file: File) => void;
onRemoveAttachment: (index: number) => void;
};
export const InputArea = ({
input,
onInputChange,
onKeyDown,
onSend,
onStop,
isGenerating,
isConnected,
commandFeedback,
textareaRef,
messages,
availableModels,
selectedModel,
onModelChange,
model,
attachments,
onAttachWebpage,
onAttachImage,
onRemoveAttachment,
}: InputAreaProps) => {
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const handleMicClick = async () => {
if (recording) {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
setRecording(false);
try {
if (recorder.state === 'inactive') {
recorder.stream.getTracks().forEach((t) => t.stop());
return;
}
const blob = await new Promise<Blob>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
recorder.onstop = () => {
clearTimeout(timeout);
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
chunksRef.current = [];
};
recorder.stop();
});
recorder.stream.getTracks().forEach((t) => t.stop());
if (blob.size === 0) {
toast.error('No audio was captured');
return;
}
setTranscribing(true);
try {
const wav = await blobToWav(blob);
const formData = new FormData();
formData.append('file', wav, 'recording.wav');
formData.append('temperature', '0.0');
formData.append('temperature_inc', '0.2');
formData.append('response_format', 'json');
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error);
const text = (json.text ?? '').trim();
if (text) onInputChange(input + (input.length > 0 ? ' ' : '') + text);
} finally {
setTranscribing(false);
}
} catch (err) {
recorder.stream?.getTracks().forEach((t) => t.stop());
chunksRef.current = [];
toast.error(err instanceof Error ? err.message : 'Recording failed');
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data);
};
recorder.start(250);
setRecording(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
}
};
return (
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
{commandFeedback && (
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)}
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
<div className="flex items-end gap-1 md:gap-2">
<AttachButton
onAttachImage={onAttachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
/>
<button
type="button"
disabled={transcribing}
onClick={handleMicClick}
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{transcribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording ? (
<>
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
<Square className="h-3.5 w-3.5 text-red-500" />
</>
) : (
<Mic className="h-4 w-4" />
)}
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => onInputChange(ev.target.value)}
onKeyDown={onKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) onAttachImage(file);
return;
}
}
}}
placeholder="Type a message..."
rows={1}
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{isGenerating ? (
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
<Square className="h-4 w-4" />
</Button>
) : (
<Button
onClick={onSend}
disabled={!input.trim() || !isConnected}
size="icon"
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<ModelSelector
messages={messages}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={model}
isConnected={isConnected}
isGenerating={isGenerating}
/>
<WebpageDialog
open={urlDialogOpen}
onOpenChange={setUrlDialogOpen}
onSubmit={onAttachWebpage}
/>
</div>
);
};
-18
View File
@@ -1,18 +0,0 @@
export { MessageList } from './MessageList';
export { MessageBubble, StreamingBubble } from './MessageBubble';
export { ToolActivity } from './ToolActivity';
export { QuestionActivity } from './QuestionActivity';
export { ModelSelector } from './ModelSelector';
export { InputArea } from './InputArea';
export { ChatLauncher } from './ChatLauncher';
export { AttachmentList } from './AttachmentList';
export { AttachButton } from './AttachButton';
export { WebpageDialog } from './WebpageDialog';
export { EmbeddableChat } from './EmbeddableChat';
export { usePi, type UsePiType } from './usePi';
export { ChatList } from './ChatList';
export { useSlashCommands } from './useSlashCommands';
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
export { useChatSession, type UseChatSessionType } from './useChatSession';
export * from './types';
-225
View File
@@ -1,225 +0,0 @@
import { useRef, useState, useCallback } from 'react';
import { RefreshCw, FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check, Download } from 'lucide-react';
type ToolbarProps = {
onRefresh: () => void;
onCreateDir: (name: string) => void;
onUpload: (files: FileList) => void;
selectionCount: number;
hasClipboard: boolean;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
onClearSelection: () => void;
};
export const Toolbar = ({
onRefresh,
onCreateDir,
onUpload,
selectionCount,
hasClipboard,
onCut,
onCopy,
onPaste,
onDownloadSelected,
onDeleteSelected,
onClearSelection,
}: ToolbarProps) => {
const [showInput, setShowInput] = useState(false);
const [folderName, setFolderName] = useState('');
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
const setFolderInputRef = useCallback((input: HTMLInputElement | null) => {
folderInputRef.current = input;
if (input) input.setAttribute('webkitdirectory', '');
}, []);
const handleCreate = () => {
const name = folderName.trim();
if (!name) return;
onCreateDir(name);
setFolderName('');
setShowInput(false);
};
const hiddenInputs = (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) {
onUpload(ev.target.files);
ev.target.value = '';
}
}}
/>
<input
ref={setFolderInputRef}
type="file"
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) {
onUpload(ev.target.files);
ev.target.value = '';
}
}}
/>
</>
);
const refreshBtn = (
<button
onClick={onRefresh}
title="Refresh"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<RefreshCw className="h-4 w-4" />
</button>
);
if (selectionCount > 0) {
return (
<div className="flex items-center gap-1">
{refreshBtn}
<span className="text-sm font-medium text-duck-dark/70 mr-1">
{selectionCount}
<span className="hidden md:inline"> selected</span>
</span>
<button
onClick={onCut}
title="Cut"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Scissors className="h-4 w-4" />
</button>
<button
onClick={onCopy}
title="Copy"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Copy className="h-4 w-4" />
</button>
{hasClipboard && (
<button
onClick={onPaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
<button
onClick={onDownloadSelected}
title="Download"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={onDeleteSelected}
title="Delete"
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onClearSelection}
title="Clear selection"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<X className="h-4 w-4" />
</button>
{hiddenInputs}
</div>
);
}
return (
<div className="flex items-center gap-1">
{refreshBtn}
{showInput ? (
<form
onSubmit={(ev) => {
ev.preventDefault();
handleCreate();
}}
className="flex items-center gap-2"
>
<input
autoFocus
value={folderName}
onChange={(ev) => setFolderName(ev.target.value)}
placeholder="Folder name"
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
setShowInput(false);
setFolderName('');
}
}}
/>
<button
type="submit"
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
title="Create"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setFolderName('');
}}
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<button
onClick={() => setShowInput(true)}
title="New folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderPlus className="h-4 w-4" />
</button>
)}
<button
onClick={() => fileInputRef.current?.click()}
title="Upload files"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Upload className="h-4 w-4" />
</button>
<button
onClick={() => folderInputRef.current?.click()}
title="Upload folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderUp className="h-4 w-4" />
</button>
{hasClipboard && (
<button
onClick={onPaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
{hiddenInputs}
</div>
);
};
-268
View File
@@ -1,268 +0,0 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from './useFiles';
import { Breadcrumb } from './Breadcrumb';
import { useRecentFiles } from './useRecentFiles';
import { usePinnedFiles } from './usePinnedFiles';
import { Widget } from 'widgets/Widget';
type Tab = 'browse' | 'recent' | 'pinned';
const TABS: { key: Tab; label: string; icon: typeof Folder }[] = [
{ key: 'browse', label: 'Browse', icon: FolderOpen },
{ key: 'recent', label: 'Recent', icon: Clock },
{ key: 'pinned', label: 'Pinned', icon: Pin },
];
export const FileBrowser = () => {
const navigate = useNavigate();
const { listDir, search } = useFiles();
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
const [tab, setTab] = useState<Tab>('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
setLoading(true);
listDir(browsePath)
.then((res) => setEntries(res.entries))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [browsePath]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const q = searchQuery.trim();
if (!q) {
setSearchResults([]);
return;
}
debounceRef.current = setTimeout(() => {
setSearching(true);
search(q)
.then((res) => setSearchResults(res.results))
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery]);
const openFile = (path: string, name: string) => {
addRecent(path, name);
navigate(`/files?view=${encodeURIComponent(path)}`);
};
const isSearching = searchQuery.trim().length > 0;
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<Widget title="File Browser">
{/* Tab bar + search */}
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
searching ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
) : (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
/>
))}
</ul>
)
) : (
<>
{tab === 'browse' && (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : sorted.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{sorted.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
)}
{tab === 'recent' && (
<div className="pt-2">
{recents.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
) : (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
{tab === 'pinned' && (
<div className="pt-2">
{pinned.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
) : (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
</Widget>
);
};
type EntryRowProps = {
name: string;
subtitle?: string;
type: 'file' | 'directory';
pinned?: boolean;
onPin?: () => void;
onClick: () => void;
};
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
{type === 'directory' ? (
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
) : (
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{name}</span>
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
</div>
</button>
{onPin && (
<button
onClick={(ev) => {
ev.stopPropagation();
onPin();
}}
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
pinned
? 'text-duck-teal hover:text-duck-teal/70'
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
}`}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</li>
);
-11
View File
@@ -1,11 +0,0 @@
export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { useFiles, type DirEntry } from './useFiles';
export { useTasks, type TaskSummary } from './useTasks';
export { Files } from './Files';
export { FileBrowser } from './Widget';
export { FileGrid } from './FileGrid';
export { FileItem, type FileItemProps } from './FileItem';
export { TaskRunnerModal } from './TaskRunnerModal';
export { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles';
@@ -1,23 +0,0 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type PinnedFile = { path: string; name: string; pinnedAt: number };
export const usePinnedFiles = () => {
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
const togglePin = useCallback(
(path: string, name: string) => {
setPinned((prev) => {
const exists = prev.some((f) => f.path === path);
if (exists) return prev.filter((f) => f.path !== path);
return [{ path, name, pinnedAt: Date.now() }, ...prev];
});
},
[setPinned],
);
const isPinned = useCallback((path: string) => pinned.some((f) => f.path === path), [pinned]);
return { pinned, togglePin, isPinned };
};
@@ -1,22 +0,0 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type RecentFile = { path: string; name: string; openedAt: number };
const MAX_RECENTS = 20;
export const useRecentFiles = () => {
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
const addRecent = useCallback(
(path: string, name: string) => {
setRecents((prev) => {
const filtered = prev.filter((f) => f.path !== path);
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
});
},
[setRecents],
);
return { recents, addRecent };
};
@@ -1,358 +0,0 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import type { ReactNode } from 'react';
import {
Download,
Loader2,
Music,
Film,
Image,
FileType2,
Volume2,
ArrowUp,
ScanText,
FileText,
AudioLines,
FolderArchive,
} from 'lucide-react';
import { useFiles } from 'apps/FileBrowser';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { getIcon } from 'material-file-icons';
import { getFileType, getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
import type { FileType } from './file-types';
import {
PdfRenderer,
ImageRenderer,
VideoRenderer,
AudioRenderer,
CodeRenderer,
MarkdownRenderer,
TextRenderer,
ScrollToTopButton,
} from './FileViewerView';
// ── Context ──
type FileViewerContextValue = {
filePath: string;
fileName: string;
root: string;
fileType: FileType;
content: string | null;
loading: boolean;
error: string | null;
directContent: boolean;
ttsLoading: boolean;
ocrLoading: boolean;
transcribeLoading: boolean;
extractAudioLoading: boolean;
extractLoading: boolean;
autoPlay: boolean;
handleReadAloud: () => void;
handleOcr: () => void;
handleTranscribe: () => void;
handleExtractAudio: () => void;
handleExtract: () => void;
handleDownload: () => void;
};
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
const useFileViewer = () => {
const ctx = useContext(FileViewerContext);
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
return ctx;
};
// ── Provider ──
type FileViewerProviderProps = {
filePath: string;
fileName: string;
root?: string;
content?: string;
onOpenFile?: (filePath: string, root: string) => void;
autoPlay?: boolean;
children: ReactNode;
};
export const FileViewerProvider = ({ filePath, fileName, root = 'home', content: directContent, onOpenFile, autoPlay = false, children }: FileViewerProviderProps) => {
const [content, setContent] = useState<string | null>(directContent ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ttsLoading, setTtsLoading] = useState(false);
const [ocrLoading, setOcrLoading] = useState(false);
const [transcribeLoading, setTranscribeLoading] = useState(false);
const [extractAudioLoading, setExtractAudioLoading] = useState(false);
const [extractLoading, setExtractLoading] = useState(false);
const client = useClient();
const files = useFiles(root);
const fileType = getFileType(fileName);
useEffect(() => {
if (directContent !== undefined) {
setContent(directContent);
setLoading(false);
return;
}
if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf' || fileType === 'archive') {
setLoading(false);
return;
}
setLoading(true);
setError(null);
files
.readFile(filePath)
.then((res) => setContent(res.content))
.catch(() => setError('Failed to read file'))
.finally(() => setLoading(false));
}, [filePath, directContent]);
const handleDownload = useCallback(() => {
const url = getRawUrl(filePath, root);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
}, [filePath, root, fileName]);
const handleReadAloud = useCallback(async () => {
setTtsLoading(true);
try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root });
onOpenFile?.(audioPath, audioRoot);
} catch {
toast.error('Failed to generate speech audio');
} finally {
setTtsLoading(false);
}
}, [filePath, root, client, onOpenFile]);
const handleOcr = useCallback(async () => {
setOcrLoading(true);
try {
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root });
onOpenFile?.(ocrPath, ocrRoot);
} catch {
toast.error('Failed to extract text from image');
} finally {
setOcrLoading(false);
}
}, [filePath, root, client, onOpenFile]);
const handleTranscribe = useCallback(async () => {
setTranscribeLoading(true);
try {
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root });
onOpenFile?.(transcriptionPath, transcriptionRoot);
} catch {
toast.error('Failed to transcribe audio');
} finally {
setTranscribeLoading(false);
}
}, [filePath, root, client, onOpenFile]);
const handleExtractAudio = useCallback(async () => {
setExtractAudioLoading(true);
try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root });
onOpenFile?.(audioPath, audioRoot);
} catch {
toast.error('Failed to extract audio from video');
} finally {
setExtractAudioLoading(false);
}
}, [filePath, root, client, onOpenFile]);
const handleExtract = useCallback(async () => {
setExtractLoading(true);
try {
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root });
const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`);
} catch {
toast.error('Failed to extract archive');
} finally {
setExtractLoading(false);
}
}, [filePath, root, client]);
const value: FileViewerContextValue = {
filePath,
fileName,
root,
fileType,
content,
loading,
error,
directContent: directContent !== undefined,
ttsLoading,
ocrLoading,
transcribeLoading,
extractAudioLoading,
extractLoading,
autoPlay,
handleReadAloud,
handleOcr,
handleTranscribe,
handleExtractAudio,
handleExtract,
handleDownload,
};
return <FileViewerContext value={value}>{children}</FileViewerContext>;
};
// ── Header (content fragments only — no container div) ──
export const FileViewerHeader = () => {
const { fileName, fileType, content, directContent, ttsLoading, ocrLoading, transcribeLoading, extractAudioLoading, extractLoading, handleReadAloud, handleOcr, handleTranscribe, handleExtractAudio, handleExtract, handleDownload } =
useFileViewer();
const textContent = content;
const showDownload = !directContent;
const headerIcon =
fileType === 'audio' ? (
<Music className="h-4 w-4 text-duck-teal shrink-0" />
) : fileType === 'video' ? (
<Film className="h-4 w-4 text-duck-orange shrink-0" />
) : fileType === 'image' ? (
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
) : fileType === 'pdf' ? (
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
) : fileType === 'archive' ? (
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
) : (
<span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: getIcon(fileName).svg }} />
);
return (
<>
{headerIcon}
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
<span className="text-[10px] font-mono uppercase tracking-wider shrink-0 opacity-60">
{fileType === 'code' ? getLang(fileName) : fileType}
</span>
{(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && textContent && (
<button
onClick={handleReadAloud}
disabled={ttsLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Read Aloud"
>
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
</button>
)}
{fileType === 'image' && (
<button
onClick={handleOcr}
disabled={ocrLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Text (OCR)"
>
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
</button>
)}
{fileType === 'audio' && (
<button
onClick={handleTranscribe}
disabled={transcribeLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Transcribe"
>
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
</button>
)}
{fileType === 'video' && (
<button
onClick={handleExtractAudio}
disabled={extractAudioLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Audio"
>
{extractAudioLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AudioLines className="h-3.5 w-3.5" />}
</button>
)}
{fileType === 'archive' && (
<button
onClick={handleExtract}
disabled={extractLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract"
>
{extractLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderArchive className="h-3.5 w-3.5" />}
</button>
)}
{showDownload && (
<button
onClick={handleDownload}
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
title="Download"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
</>
);
};
// ── Body (renderers) ──
export const FileViewerBody = () => {
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
const scrollRef = useRef<HTMLDivElement>(null);
const videoSrc =
fileType === 'video'
? needsTranscode(fileName)
? getTranscodeUrl(filePath, root)
: getRawUrl(filePath, root)
: '';
return (
<div ref={scrollRef} className="h-full overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">{error}</span>
</div>
) : fileType === 'pdf' ? (
<PdfRenderer src={getRawUrl(filePath, root)} />
) : fileType === 'archive' ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
<FolderArchive className="h-10 w-10" />
<span className="text-sm">Archive file</span>
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
) : fileType === 'video' ? (
<VideoRenderer src={videoSrc} fileName={fileName} />
) : fileType === 'audio' ? (
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
) : content !== null ? (
fileType === 'code' ? (
<div>
<CodeRenderer content={content} lang={getLang(fileName)} />
<ScrollToTopButton scrollContainer={scrollRef} />
</div>
) : (
<div className="px-8 py-6">
{fileType === 'markdown' ? (
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
) : (
<TextRenderer content={content} />
)}
<ScrollToTopButton scrollContainer={scrollRef} />
</div>
)
) : null}
</div>
);
};
@@ -1,774 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
X,
Loader2,
Music,
Maximize2,
Minimize2,
Play,
Pause,
Volume2,
VolumeX,
ZoomIn,
ZoomOut,
RotateCw,
ArrowUp,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSlug from 'rehype-slug';
import { getExt, formatTime } from './file-types';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
// ── Shared seek bar hook ──
function useSeekBar(mediaRef: React.RefObject<HTMLMediaElement | null>, duration: number) {
const barRef = useRef<HTMLDivElement>(null);
const seekTo = useCallback(
(clientX: number) => {
const el = mediaRef.current;
const bar = barRef.current;
if (!el || !bar || !duration) return;
const rect = bar.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
el.currentTime = pct * duration;
},
[duration, mediaRef],
);
const onSeekDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
seekTo(e.clientX);
const onMove = (ev: MouseEvent) => seekTo(ev.clientX);
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[seekTo],
);
return { barRef, onSeekDown };
}
// ── Seek bar component ──
function SeekBar({
barRef,
onSeekDown,
pct,
trackClass = 'bg-duck-dark/10',
fillClass = 'bg-duck-teal',
thumbClass = 'bg-duck-teal border-white',
}: {
barRef: React.RefObject<HTMLDivElement | null>;
onSeekDown: (e: React.MouseEvent<HTMLDivElement>) => void;
pct: number;
trackClass?: string;
fillClass?: string;
thumbClass?: string;
}) {
return (
<div ref={barRef} onMouseDown={onSeekDown} className="relative h-4 flex items-center cursor-pointer group">
<div className={`absolute left-0 right-0 h-1.5 rounded-full ${trackClass} pointer-events-none`}>
<div className={`absolute inset-y-0 left-0 rounded-full ${fillClass}`} style={{ width: `${pct}%` }} />
</div>
<div
className={`absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full shadow-md border-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none ${thumbClass} ${fillClass}`}
style={{ left: `calc(${pct}% - 7px)` }}
/>
</div>
);
}
// ── Copy button for code blocks ──
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-mono rounded bg-white/10 text-white/60 hover:text-white hover:bg-white/20 transition-colors cursor-pointer"
>
{copied ? 'Copied!' : 'Copy'}
</button>
);
}
// ── Highlighted code block for markdown ──
function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
import('shiki')
.then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' }))
.then((result) => {
if (!cancelled) setHtml(result);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [code, lang]);
if (html) {
return (
<div className="relative my-4">
<CopyButton text={code} />
{lang && (
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">
{lang}
</span>
)}
<div
className="[&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:pt-8 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono [&_pre]:leading-relaxed [&_pre]:border [&_pre]:border-white/5 [&_code]:font-mono"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
return (
<pre className="relative rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 overflow-x-auto text-sm font-mono leading-relaxed my-4 border border-white/5">
<CopyButton text={code} />
{lang && (
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider">
{lang}
</span>
)}
<code className="block pt-4">{code}</code>
</pre>
);
}
// ── Scroll to top button ──
export function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject<HTMLDivElement | null> }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = scrollContainer.current;
if (!el) return;
const onScroll = () => setVisible(el.scrollTop > 200);
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [scrollContainer]);
if (!visible) return null;
return (
<button
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
title="Scroll to top"
>
<ArrowUp className="h-4 w-4" />
</button>
);
}
// ── Markdown renderer ──
export const MarkdownRenderer = ({
content,
scrollContainer,
}: {
content: string;
scrollContainer: React.RefObject<HTMLDivElement | null>;
}) => {
const handleAnchorClick = useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const target = (ev.target as HTMLElement).closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href?.startsWith('#')) return;
ev.preventDefault();
const id = href.slice(1);
const el = scrollContainer.current?.querySelector(`#${CSS.escape(id)}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
[scrollContainer],
);
return (
<article className="file-viewer-md" onClick={handleAnchorClick}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug]}
components={{
pre({ children }) {
return <div className="relative">{children}</div>;
},
code({ className, children, ...props }) {
const isBlock = className?.startsWith('language-');
const lang = className?.replace('language-', '') ?? '';
const text = String(children).replace(/\n$/, '');
if (!isBlock) {
return (
<code
className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono"
{...props}
>
{children}
</code>
);
}
return <HighlightedCodeBlock code={text} lang={lang} />;
},
}}
>
{content}
</ReactMarkdown>
</article>
);
};
// ── Text renderer ──
export const TextRenderer = ({ content }: { content: string }) => (
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
);
// ── Code renderer with syntax highlighting ──
export const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
import('shiki')
.then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' }))
.then((result) => {
if (!cancelled) setHtml(result);
})
.catch(() => {
if (!cancelled) setHtml(null);
});
return () => {
cancelled = true;
};
}, [content, lang]);
if (html === null) {
return <TextRenderer content={content} />;
}
return (
<div
className="code-highlight text-sm leading-relaxed [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_code]:font-mono"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
// ── Audio renderer ──
export const AudioRenderer = ({ src, fileName, autoPlay = false }: { src: string; fileName: string; autoPlay?: boolean }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const { barRef, onSeekDown } = useSeekBar(audioRef, duration);
const ext = fileName.split('.').pop()?.toUpperCase() ?? 'AUDIO';
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const onLoaded = () => {
setDuration(a.duration);
setLoaded(true);
if (autoPlay) {
a.play();
setPlaying(true);
}
};
const onTime = () => setCurrent(a.currentTime);
const onEnded = () => setPlaying(false);
const onError = () => setError(true);
a.addEventListener('loadedmetadata', onLoaded);
a.addEventListener('timeupdate', onTime);
a.addEventListener('ended', onEnded);
a.addEventListener('error', onError);
return () => {
a.removeEventListener('loadedmetadata', onLoaded);
a.removeEventListener('timeupdate', onTime);
a.removeEventListener('ended', onEnded);
a.removeEventListener('error', onError);
};
}, []);
const togglePlay = useCallback(() => {
const a = audioRef.current;
if (!a) return;
if (playing) a.pause();
else a.play();
setPlaying(!playing);
}, [playing]);
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(ev.target.value);
setVolume(v);
setMuted(v === 0);
if (audioRef.current) audioRef.current.volume = v;
}, []);
const toggleMute = useCallback(() => {
const a = audioRef.current;
if (!a) return;
if (muted) {
a.volume = volume || 1;
setMuted(false);
} else {
a.volume = 0;
setMuted(true);
}
}, [muted, volume]);
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load audio file</span>
</div>
);
}
return (
<div className="flex items-center justify-center h-full px-8">
<audio ref={audioRef} src={src} preload="metadata" />
<div className="w-full max-w-xl space-y-6">
<div className="flex flex-col items-center gap-4">
<div className="relative w-32 h-32 rounded-2xl bg-gradient-to-br from-duck-teal/20 via-duck-forest/10 to-duck-yellow/20 border-2 border-duck-dark/10 flex items-center justify-center shadow-lg">
<Music className="h-12 w-12 text-duck-teal/60" />
<span className="absolute bottom-2 right-2 text-[9px] font-mono font-bold text-duck-dark/30 tracking-wider">
{ext}
</span>
</div>
<div className="text-center">
<p className="text-sm font-semibold text-duck-dark truncate max-w-xs">{fileName}</p>
{loaded && <p className="text-xs text-duck-dark/40 mt-0.5">{formatTime(duration)}</p>}
</div>
</div>
<div className="space-y-1.5">
<SeekBar barRef={barRef} onSeekDown={onSeekDown} pct={pct} />
<div className="flex justify-between text-[10px] font-mono text-duck-dark/40">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<button
onClick={toggleMute}
className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
className="w-20 h-1 accent-duck-teal cursor-pointer"
/>
</div>
<button
onClick={togglePlay}
disabled={!loaded}
className="w-14 h-14 rounded-full bg-duck-teal text-white flex items-center justify-center shadow-lg hover:bg-duck-teal/90 disabled:opacity-40 transition-all cursor-pointer active:scale-95"
>
{!loaded ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : playing ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-0.5" />
)}
</button>
<div className="w-[104px]" />
</div>
</div>
</div>
);
};
// ── Video renderer ──
export const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const { barRef, onSeekDown } = useSeekBar(videoRef, duration);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onLoaded = () => {
setDuration(v.duration);
setLoaded(true);
};
const onTime = () => setCurrent(v.currentTime);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
const onError = () => setError(true);
v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime);
v.addEventListener('play', onPlay);
v.addEventListener('pause', onPause);
v.addEventListener('ended', onEnded);
v.addEventListener('error', onError);
return () => {
v.removeEventListener('loadedmetadata', onLoaded);
v.removeEventListener('timeupdate', onTime);
v.removeEventListener('play', onPlay);
v.removeEventListener('pause', onPause);
v.removeEventListener('ended', onEnded);
v.removeEventListener('error', onError);
};
}, []);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const resetHideTimer = useCallback(() => {
setShowControls(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => {
if (videoRef.current && !videoRef.current.paused) setShowControls(false);
}, 2500);
}, []);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play();
else v.pause();
}, []);
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(ev.target.value);
setVolume(val);
setMuted(val === 0);
if (videoRef.current) videoRef.current.volume = val;
}, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (muted) {
v.volume = volume || 1;
setMuted(false);
} else {
v.volume = 0;
setMuted(true);
}
}, [muted, volume]);
const toggleFullscreen = useCallback(() => {
const c = containerRef.current;
if (!c) return;
if (document.fullscreenElement) document.exitFullscreen();
else c.requestFullscreen();
}, []);
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load video</span>
</div>
);
}
return (
<div
ref={containerRef}
className="relative w-full h-full bg-black flex items-center justify-center group"
onMouseMove={resetHideTimer}
onMouseLeave={() => {
if (playing) setShowControls(false);
}}
>
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
{loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
<Play className="h-8 w-8 text-white ml-1" />
</div>
</button>
)}
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-8 w-8 text-white animate-spin" />
</div>
)}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<SeekBar
barRef={barRef}
onSeekDown={onSeekDown}
pct={pct}
trackClass="bg-white/20"
fillClass="bg-duck-teal"
thumbClass="bg-duck-teal border-white"
/>
<div className="flex items-center gap-3 mt-1">
<button onClick={togglePlay} className="p-1 text-white hover:text-duck-teal transition-colors cursor-pointer">
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
<div className="flex items-center gap-1.5">
<button
onClick={toggleMute}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
className="w-16 h-1 accent-duck-teal cursor-pointer"
/>
</div>
<span className="text-xs font-mono text-white/60 select-none">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<span className="text-[9px] font-mono text-white/30 uppercase tracking-wider">{getExt(fileName)}</span>
<button
onClick={toggleFullscreen}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
</div>
);
};
// ── Image renderer ──
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
export const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
const containerRef = useRef<HTMLDivElement>(null);
const minZoom = ZOOM_STEPS[0] ?? 1;
const maxZoom = ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? 1;
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
const zoomIn = useCallback(() => {
setZoom((z) => {
const next = ZOOM_STEPS.find((s) => s > z);
return next ?? z;
});
}, []);
const zoomOut = useCallback(() => {
setZoom((z) => {
const prev = [...ZOOM_STEPS].reverse().find((s) => s < z);
return prev ?? z;
});
}, []);
const resetView = useCallback(() => {
setZoom(1);
setRotation(0);
setOffset({ x: 0, y: 0 });
}, []);
const rotate = useCallback(() => {
setRotation((r) => (r + 90) % 360);
}, []);
const onWheel = useCallback(
(ev: React.WheelEvent) => {
ev.preventDefault();
if (ev.deltaY < 0) zoomIn();
else zoomOut();
},
[zoomIn, zoomOut],
);
const onMouseDown = useCallback(
(ev: React.MouseEvent) => {
if (zoom <= 1) return;
ev.preventDefault();
setDragging(true);
dragStart.current = { x: ev.clientX, y: ev.clientY, ox: offset.x, oy: offset.y };
const onMove = (e: MouseEvent) => {
setOffset({
x: dragStart.current.ox + (e.clientX - dragStart.current.x),
y: dragStart.current.oy + (e.clientY - dragStart.current.y),
});
};
const onUp = () => {
setDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[zoom, offset],
);
useEffect(() => {
if (zoom <= 1) setOffset({ x: 0, y: 0 });
}, [zoom]);
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load image</span>
</div>
);
}
return (
<div className="relative w-full h-full flex flex-col">
<div
ref={containerRef}
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
}`}
onWheel={onWheel}
onMouseDown={zoom > 1 ? onMouseDown : undefined}
onClick={zoom <= 1 ? zoomIn : undefined}
>
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
)}
<img
src={src}
alt={fileName}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
className="transition-transform duration-150 select-none"
draggable={false}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
maxWidth: zoom <= 1 ? '100%' : 'none',
maxHeight: zoom <= 1 ? '100%' : 'none',
opacity: loaded ? 1 : 0,
}}
/>
</div>
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
<button
onClick={zoomOut}
disabled={zoom <= minZoom}
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
>
<ZoomOut className="h-4 w-4" />
</button>
<button
onClick={resetView}
className="px-2 py-1 rounded-md text-xs font-mono text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer min-w-[4rem] text-center"
>
{Math.round(zoom * 100)}%
</button>
<button
onClick={zoomIn}
disabled={zoom >= maxZoom}
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="w-px h-4 bg-duck-dark/10 mx-1" />
<button
onClick={rotate}
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer"
>
<RotateCw className="h-4 w-4" />
</button>
</div>
</div>
);
};
// ── PDF renderer ──
export const PdfRenderer = ({ src }: { src: string }) => (
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
);
// ── Backward-compat FileViewerView wrapper ──
type FileViewerViewProps = {
filePath: string;
fileName: string;
root?: string;
content?: string;
onClose?: () => void;
};
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => {
return (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
<div className="h-full flex flex-col">
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
<FileViewerHeader />
{onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0">
<FileViewerBody />
</div>
</div>
</FileViewerProvider>
);
};
-3
View File
@@ -1,3 +0,0 @@
export { FileViewerView } from './FileViewerView';
export { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
-12
View File
@@ -1,12 +0,0 @@
{
"name": "apps",
"private": true,
"exports": {
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts",
"./CodeEditor": "./CodeEditor/index.ts",
"./FileViewer": "./FileViewer/index.ts"
}
}
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import type { LayoutNode, AppRegistry, WorkspaceState } from './types'; import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
import type { LayoutNode, AppRegistry, WorkspaceState, EphemeralPanels } from './types';
import type { DropPosition } from './layout-utils'; import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils'; import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext'; import { WorkspaceProvider } from './WorkspaceContext';
@@ -10,9 +11,12 @@ type WorkspaceViewProps = {
workspace: WorkspaceState; workspace: WorkspaceState;
registry: AppRegistry; registry: AppRegistry;
cwd?: string; cwd?: string;
ephemeral?: EphemeralPanels | null;
}; };
export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewProps) => { const noop = () => {};
export const WorkspaceView = ({ workspace, registry, cwd = '~', ephemeral }: WorkspaceViewProps) => {
if (!workspace.isLoaded) return null; if (!workspace.isLoaded) return null;
const { value: layout, setValue: onLayoutChange } = workspace; const { value: layout, setValue: onLayoutChange } = workspace;
@@ -101,6 +105,40 @@ export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewP
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]); }, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
const baseRenderer = (
<WorkspaceRenderer
layout={layout}
registry={registry}
interactive
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
);
const content = ephemeral ? (
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={ephemeral.defaultBaseSize ?? 40} minSize={15}>
{baseRenderer}
</ResizablePanel>
<ResizableHandle className="bg-transparent after:bg-transparent" />
<ResizablePanel defaultSize={100 - (ephemeral.defaultBaseSize ?? 40)} minSize={15}>
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
</ResizablePanel>
</ResizablePanelGroup>
) : (
baseRenderer
);
return ( return (
<WorkspaceProvider <WorkspaceProvider
value={{ value={{
@@ -117,16 +155,7 @@ export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewP
transitioningPanelId, transitioningPanelId,
}} }}
> >
<WorkspaceRenderer {content}
layout={layout}
registry={registry}
interactive
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
{/* <DragOverlay /> */}
</WorkspaceProvider> </WorkspaceProvider>
); );
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types'; export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels } from './types';
export type { DropPosition } from './layout-utils'; export type { DropPosition } from './layout-utils';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils'; export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext'; export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
@@ -67,3 +67,9 @@ export type PanelComponentEntry = {
}; };
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>; export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
export type EphemeralPanels = {
layout: LayoutNode;
components: PanelComponents;
defaultBaseSize?: number;
};
@@ -0,0 +1,151 @@
# App Conventions
Patterns and conventions for building apps in the `officerdev` workspace. These complement the root `CLAUDE.md` and `CONVENTIONS.md`.
## Manager Pattern
Each app has a central hook that owns all state, effects, and handlers. Components receive this single object as a prop and destructure what they need internally.
```tsx
// Hook
export const useFileBrowserApp = (basePath: string) => {
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(true);
// ... all state, effects, handlers
return { entries, loading, refresh, handleOpen, /* ... */ };
};
export type UseFileBrowserAppType = ReturnType<typeof useFileBrowserApp>;
```
```tsx
// Component receives the manager as a single prop
type ToolbarProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
const { searchQuery, setSearchQuery, viewMode, setViewMode } = fileBrowserManager;
// ...
};
```
```tsx
// Root component creates the manager and passes it down
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath);
return (
<div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} />
</div>
);
};
```
**Exception**: Presentational leaf components that receive computed/derived values (e.g. `FileItem`, `EntryRow`) keep individual props since they don't interact with the manager directly.
## Directory Structure
Apps live in `src/apps/<AppName>/` with this layout:
```
FileBrowser/
├── index.ts # Public barrel (exports for external consumers)
├── useFiles.ts # Shared API hooks
├── useTasks.ts
├── FileBrowserApp/
│ ├── index.ts # Re-exports FileBrowserApp
│ ├── FileBrowserApp.tsx # Slim composition root
│ ├── useFileBrowserApp.ts # Manager hook
│ └── components/
│ ├── Breadcrumb.tsx
│ ├── FileGrid.tsx
│ ├── FileViewContainer.tsx
│ └── Toolbar/
│ ├── index.ts
│ ├── Toolbar.tsx
│ ├── ActionButtons.tsx
│ ├── SelectionActions.tsx
│ └── DefaultActions.tsx
└── FileBrowserWidget/
├── index.ts
├── FileBrowserWidget.tsx
├── useFileBrowserWidget.ts
└── components/
├── Header.tsx
├── BrowseTab.tsx
└── EntryRow.tsx
```
Key rules:
- Feature directories use `Feature/Feature.tsx` + `Feature/index.ts` barrel
- Components that warrant further breakdown get their own directory (e.g. `Toolbar/`)
- Shared components can be imported across sibling apps (e.g. Widget imports `Breadcrumb` from App)
- The root `index.ts` is the public API — only export what external consumers need
## File Ordering
Within a file, order sections as:
1. Imports (types → external → workspace → relative)
2. Constants
3. Main export (component or hook)
4. `ReturnType` export (for hooks)
5. Types (props types for sub-components)
6. Helper components / functions
## No useCallback or useMemo (React 19)
React 19's compiler handles memoization. Never use `useCallback` or `useMemo`.
```tsx
// Plain function — not wrapped in useCallback
const refresh = async () => {
setLoading(true);
const data = await files.listDir(currentPath);
setEntries(data.entries);
setLoading(false);
};
// Inline ref callback — not wrapped in useCallback
<input ref={(node) => {
inputRef.current = node;
if (node) node.setAttribute('webkitdirectory', '');
}} />
// IIFE for derived values — not wrapped in useMemo
const sorted = (() => {
const compare = (a: DirEntry, b: DirEntry): number => { /* ... */ };
return [...entries].sort(compare);
})();
// Simple derivations — just inline
const svg = getIcon(name).svg;
const isSearching = searchQuery.trim().length > 0;
```
## Component Extraction
Break monolithic components into focused sub-components in a `components/` directory. Each handles its own null guards internally.
```tsx
// Component handles its own visibility logic
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
const { uploadProgress } = fileBrowserManager;
if (uploadProgress === null) return null;
return <div>/* progress bar */</div>;
};
// Parent stays clean — no conditional rendering
<UploadProgress fileBrowserManager={fileBrowserManager} />
```
## Keep It Simple
- Use `prompt()` for simple user input instead of inline forms with state management
- Group hidden `<input type="file">` elements inside their trigger buttons
- Don't over-decompose — if a component is under ~30 lines of JSX, it probably doesn't need extraction
- Prefer early returns over nested ternaries for loading/empty states
@@ -0,0 +1,119 @@
# Hook Conventions
Patterns and conventions for building hooks in the `officerdev` workspace. These complement the root `CLAUDE.md` and `APP_CONVENTIONS.md`.
## File Ordering
Within a hook file, order sections as:
1. Imports (types → external → workspace → relative)
2. Constants
3. Main export (the hook)
4. `export type UseXType = ReturnType<typeof useX>`
5. Types (params types, internal types)
6. Helper functions
```ts
import type { ChatMessage } from '../apps/Chat/types';
import { useState, useRef } from 'react';
import { useClient } from 'hooks/useClient';
const DEBOUNCE_MS = 1000;
export function usePiChat(sessionId?: string, model?: string | null) {
// ...
return { messages, sendPrompt, stopGeneration };
}
export type UsePiChatType = ReturnType<typeof usePiChat>;
// ── Types ──
type InternalMessage = {
id: string;
timestamp: number;
};
// ── Helpers ──
const formatMessage = (msg: InternalMessage) => { /* ... */ };
```
## Simple Hooks — Single File
Hooks that are self-contained (one function, a few types, maybe some helpers) stay as a single file in `src/hooks/`.
```
hooks/
├── useFilesAPI.ts
├── usePiChat.ts
└── index.ts
```
The file is named after the hook: `useFilesAPI.ts` exports `useFilesAPI`.
## Complex Hooks — Directory
When a hook accumulates supporting code (constants, components, config), promote it to a directory:
```
hooks/
├── useFileViewerPanels/
│ ├── index.ts # Barrel re-export
│ ├── useFileViewerPanels.tsx # The hook
│ ├── layouts.ts # Static data / constants
│ └── Providers.tsx # Helper components
└── index.ts
```
Rules:
- `index.ts` is a barrel — only re-exports the hook and its `ReturnType`
- The hook file is named `useX.ts(x)` matching the directory name
- Supporting files are named by what they contain: `layouts.ts`, `Providers.tsx`
- Component files are **PascalCased** (e.g. `Providers.tsx`), everything else is **kebab-case** or **camelCase**
- The parent `hooks/index.ts` barrel doesn't change when promoting — `export * from './useFileViewerPanels'` resolves to the directory's `index.ts`
## When to Promote
Promote a single-file hook to a directory when:
- It has **3+ non-trivial constants** that deserve their own file (layouts, configs, mappings)
- It contains **React components** used as helpers (providers, wrappers)
- The file exceeds **~200 lines** and has clear separable concerns
Don't promote for:
- A couple of small types or helper functions — keep them in the same file
- Hooks that are just API wrappers (like `useFilesAPI`)
## Return Type Export
Always export a `ReturnType` alias immediately after the hook. This lets consumers type props without importing the hook itself:
```ts
export function useFilesAPI(root: string = 'home') {
// ...
}
export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
```
```tsx
// Consumer uses the type without calling the hook
import type { UseFilesAPIType } from 'officerdev';
type ToolbarProps = {
files: UseFilesAPIType;
};
```
## Barrel Exports
The root `hooks/index.ts` re-exports everything. Each entry is a single `export *` line:
```ts
export * from './appRegistry';
export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './usePiChat';
```
When adding a new hook, add one line here. Order alphabetically.
+4
View File
@@ -6,5 +6,9 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
},
"dependencies": {
"hooks": "workspace:*",
"widgets": "workspace:*"
} }
} }
@@ -0,0 +1,120 @@
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ModelOption } from '../types';
import { useAttachments } from '../useAttachments';
import { ModelSelector } from '../components/ModelSelector';
import { AttachmentList } from '../components/AttachmentList';
import { AttachButton } from '../components/AttachButton';
import { WebpageDialog } from '../components/WebpageDialog';
type ChatLauncherProps = {
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
onSubmit: (data: {
prompt: string;
model: string | null;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
}) => void;
placeholder?: string;
};
export function ChatLauncher({
availableModels,
selectedModel,
onModelChange,
onSubmit,
placeholder = 'What do you want to work on now?',
}: ChatLauncherProps) {
const { attachments, attachWebpage, attachImage, removeAttachment, clearAttachments, processAttachments } =
useAttachments();
const [input, setInput] = useState('');
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const handleSubmit = () => {
const text = input.trim();
if (!text) return;
const { prefix, ids, images } = processAttachments();
const prompt = prefix ? `${prefix}${text}` : text;
onSubmit({
prompt,
model: selectedModel,
attachmentIds: ids.length > 0 ? ids : undefined,
images: images.length > 0 ? images : undefined,
});
setInput('');
clearAttachments();
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSubmit();
}
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
}, [input]);
return (
<div className="p-4 pb-2 pt-1">
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
<div className="flex items-end gap-2">
<AttachButton size="md" onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} />
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) attachImage(file);
return;
}
}
}}
placeholder={placeholder}
rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
/>
<Button
onClick={handleSubmit}
disabled={!input.trim()}
size="icon"
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
</div>
<ModelSelector
messages={[]}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={selectedModel}
isConnected={true}
isGenerating={false}
/>
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
</div>
);
}
@@ -0,0 +1 @@
export { ChatLauncher } from './ChatLauncher';
@@ -0,0 +1,32 @@
import type { UsePiChatType } from '../../../hooks/usePiChat';
import { useEmbeddableChat } from './useEmbeddableChat';
import { MessageList } from '../components/MessageList';
import { InputArea } from '../components/InputArea';
type EmbeddableChatProps = {
sessionId?: string;
initialModel?: string | null;
initialMessage?: {
text: string;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
cwd?: { root?: string; path: string };
};
defaultInput?: string;
promptPrefix?: string;
className?: string;
cwd?: { root?: string; path: string };
autoSend?: boolean;
chat?: UsePiChatType;
};
export const EmbeddableChat = ({ className, ...params }: EmbeddableChatProps) => {
const manager = useEmbeddableChat(params);
return (
<div className={`flex flex-col ${className ?? ''}`}>
<MessageList manager={manager} />
<InputArea manager={manager} />
</div>
);
};
@@ -0,0 +1,2 @@
export { EmbeddableChat } from './EmbeddableChat';
export { useEmbeddableChat, type UseEmbeddableChatType } from './useEmbeddableChat';
@@ -0,0 +1,195 @@
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useVisiblePiModels } from '@/state/useModels';
import { usePiChat, type UsePiChatType } from '../../../hooks/usePiChat';
import { useAttachments } from '../useAttachments';
import { useSlashCommands } from '../useSlashCommands';
type UseEmbeddableChatParams = {
sessionId?: string;
initialModel?: string | null;
initialMessage?: {
text: string;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
cwd?: { root?: string; path: string };
};
defaultInput?: string;
promptPrefix?: string;
cwd?: { root?: string; path: string };
autoSend?: boolean;
chat?: UsePiChatType;
};
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
const internalChat = usePiChat(params.sessionId, params.initialModel);
const chat = externalChat ?? internalChat;
const {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel,
sendPrompt,
stopGeneration,
} = chat;
const availableModels = useVisiblePiModels();
const attachmentManager = useAttachments({ sessionId });
const slashCommands = useSlashCommands({ sessionId });
const [input, setInput] = useState(defaultInput);
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const handleSend = async () => {
const text = input.trim();
if (!text || isGenerating) return;
// Handle slash commands
if (text.startsWith('/')) {
const result = await slashCommands.execute(text);
if (result.handled) {
setCommandFeedback(result.feedback);
setInput('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
return;
}
}
setCommandFeedback(null);
const { prefix, ids, images } = attachmentManager.processAttachments();
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
if (prefix) prompt = `${prefix}${prompt}`;
const cwdForFirst = !sessionId ? cwd : undefined;
sendPrompt(
prompt,
!sessionId && ids.length > 0 ? ids : undefined,
images.length > 0 ? images : undefined,
cwdForFirst,
);
attachmentManager.clearAttachments();
setInput('');
userScrolledRef.current = false;
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSend();
}
};
const jumpToBottom = () => {
userScrolledRef.current = false;
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const appendToInput = (text: string) => {
setInput((prev) => prev + (prev.length > 0 ? ' ' : '') + text);
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}, [input]);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (!userScrolledRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, streamingText]);
// Detect user scrolling up
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
userScrolledRef.current = !atBottom;
setShowJumpToBottom(!atBottom);
};
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
}, []);
// Focus textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
// Auto-send initial message
const initialSentRef = useRef(false);
useEffect(() => {
if (initialMessage && isConnected && !initialSentRef.current) {
initialSentRef.current = true;
if (params.initialModel) setSelectedModel(params.initialModel);
sendPrompt(
initialMessage.text,
initialMessage.attachmentIds,
initialMessage.images,
initialMessage.cwd,
);
}
}, [initialMessage, isConnected]);
// Auto-send when autoSend is enabled
const autoSentRef = useRef(false);
useEffect(() => {
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
autoSentRef.current = true;
handleSend();
}
}, [autoSend, isConnected, messages.length, input]);
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel,
sendPrompt,
stopGeneration,
input,
setInput,
handleSend,
handleKeyDown,
appendToInput,
attachments: attachmentManager.attachments,
attachWebpage: attachmentManager.attachWebpage,
attachImage: attachmentManager.attachImage,
removeAttachment: attachmentManager.removeAttachment,
availableModels,
showJumpToBottom,
jumpToBottom,
commandFeedback,
textareaRef,
scrollViewportRef,
bottomRef,
};
}
export type UseEmbeddableChatType = ReturnType<typeof useEmbeddableChat>;
@@ -1,5 +1,5 @@
import { Loader2, Image, Link, X } from 'lucide-react'; import { Loader2, Image, Link, X } from 'lucide-react';
import type { Attachment } from './types'; import type { Attachment } from '../types';
type AttachmentListProps = { type AttachmentListProps = {
attachments: Attachment[]; attachments: Attachment[];
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { Loader2, Mic, Send, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { useAudioRecording } from '../useAudioRecording';
import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
type InputAreaProps = {
manager: UseEmbeddableChatType;
};
export const InputArea = ({ manager }: InputAreaProps) => {
const {
input,
setInput,
handleSend,
handleKeyDown,
stopGeneration,
isGenerating,
isConnected,
commandFeedback,
textareaRef,
messages,
availableModels,
selectedModel,
setSelectedModel,
model,
attachments,
attachWebpage,
attachImage,
removeAttachment,
appendToInput,
} = manager;
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
return (
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
{commandFeedback && (
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)}
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
<div className="flex items-end gap-1 md:gap-2">
<AttachButton onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} />
<MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} />
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) attachImage(file);
return;
}
}
}}
placeholder="Type a message..."
rows={1}
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{isGenerating ? (
<Button onClick={stopGeneration} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
<Square className="h-4 w-4" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={!input.trim() || !isConnected}
size="icon"
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<ModelSelector
messages={messages}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
model={model}
isConnected={isConnected}
isGenerating={isGenerating}
/>
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
</div>
);
};
// ── Helpers ──
type MicButtonProps = {
recording: boolean;
transcribing: boolean;
onToggle: () => void;
};
const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
<button
type="button"
disabled={transcribing}
onClick={onToggle}
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{transcribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording ? (
<>
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
<Square className="h-3.5 w-3.5 text-red-500" />
</>
) : (
<Mic className="h-4 w-4" />
)}
</button>
);
@@ -1,7 +1,7 @@
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import type { ChatMessage } from './types'; import type { ChatMessage } from '../types';
import { ToolActivity } from './ToolActivity'; import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity'; import { QuestionActivity } from './QuestionActivity';
@@ -1,33 +1,21 @@
import { ArrowDown } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import type { ChatMessage } from './types'; import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { MessageBubble, StreamingBubble } from './MessageBubble'; import { MessageBubble, StreamingBubble } from './MessageBubble';
const OVERSCAN = 5; const OVERSCAN = 5;
export const MessageList = ({ type MessageListProps = {
messages, manager: UseEmbeddableChatType;
streamingText, };
isGenerating,
showJumpToBottom, export const MessageList = ({ manager }: MessageListProps) => {
onJumpToBottom, const { messages, streamingText, isGenerating, showJumpToBottom, jumpToBottom, sendPrompt, scrollViewportRef, bottomRef } = manager;
onQuestionAnswer,
scrollViewportRef,
bottomRef,
}: {
messages: ChatMessage[];
streamingText: string;
isGenerating: boolean;
showJumpToBottom: boolean;
onJumpToBottom: () => void;
onQuestionAnswer?: (text: string) => void;
scrollViewportRef: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>;
}) => {
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: messages.length, count: messages.length,
getScrollElement: () => scrollViewportRef.current, getScrollElement: () => scrollViewportRef.current,
estimateSize: () => 150, // Initial estimate, will be measured estimateSize: () => 150,
overscan: OVERSCAN, overscan: OVERSCAN,
measureElement: (element) => element.getBoundingClientRect().height, measureElement: (element) => element.getBoundingClientRect().height,
}); });
@@ -58,7 +46,7 @@ export const MessageList = ({
}} }}
> >
<div className="py-1.5"> <div className="py-1.5">
<MessageBubble message={msg} onAnswer={onQuestionAnswer} /> <MessageBubble message={msg} onAnswer={(text) => sendPrompt(text)} />
</div> </div>
</div> </div>
); );
@@ -75,7 +63,7 @@ export const MessageList = ({
{showJumpToBottom && ( {showJumpToBottom && (
<button <button
onClick={onJumpToBottom} onClick={jumpToBottom}
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer" className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer"
> >
<ArrowDown className="h-4 w-4" /> <ArrowDown className="h-4 w-4" />
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from '@/state/useModels'; import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from './types'; import type { ChatMessage } from '../types';
const PROVIDER_DISPLAY: Record<string, string> = { const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic', anthropic: 'Anthropic',
@@ -39,10 +38,7 @@ export function ModelSelector({
isConnected, isConnected,
isGenerating, isGenerating,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const providers = useMemo( const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
[availableModels],
);
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0]; const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
const providerModels = availableModels.filter((m) => m.provider === activeProvider); const providerModels = availableModels.filter((m) => m.provider === activeProvider);
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { MessageCircleQuestion, Check } from 'lucide-react'; import { MessageCircleQuestion, Check } from 'lucide-react';
import type { ChatMessage } from './types'; import type { ChatMessage } from '../types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>; type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react'; import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import type { ChatMessage } from './types'; import type { ChatMessage } from '../types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>; type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
@@ -0,0 +1,20 @@
export { MessageList } from './components/MessageList';
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
export { ToolActivity } from './components/ToolActivity';
export { QuestionActivity } from './components/QuestionActivity';
export { ModelSelector } from './components/ModelSelector';
export { InputArea } from './components/InputArea';
export { ChatLauncher } from './ChatLauncher';
export { AttachmentList } from './components/AttachmentList';
export { AttachButton } from './components/AttachButton';
export { WebpageDialog } from './components/WebpageDialog';
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
export { usePiChat, type UsePiChatType } from '../../hooks/usePiChat';
export { ChatList } from './ChatList';
export { useSlashCommands } from './useSlashCommands';
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
export { useChatSession, type UseChatSessionType } from './useChatSession';
export { useAttachments, type UseAttachmentsType } from './useAttachments';
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
export * from './types';
@@ -0,0 +1,102 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { Attachment } from './types';
type UseAttachmentsParams = {
sessionId?: string | null;
};
export function useAttachments(params?: UseAttachmentsParams) {
const client = useClient();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const attachWebpage = async (url: string) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
]);
try {
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
...(params?.sessionId ? { sessionId: params.sessionId } : {}),
provider: 'pi-mono',
});
setAttachments((prev) =>
prev.map((a, i) =>
i === idx
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
: a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to scrape webpage');
}
};
const attachImage = async (file: File) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
]);
try {
const formData = new FormData();
formData.append('file', file);
if (params?.sessionId) formData.append('sessionId', params.sessionId);
formData.append('provider', 'pi-mono');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
prev.map((a, i) =>
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to upload image');
}
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
const clearAttachments = () => {
setAttachments([]);
};
const processAttachments = () => {
let prefix = '';
const ids: string[] = [];
const images: { filename: string; dataUrl: string }[] = [];
for (const a of attachments) {
if (a.loading) continue;
if (a.type === 'webpage' && a.content) {
prefix = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prefix}`;
} else if (a.type === 'image' && a.dataUrl) {
prefix = `[Attached image: ${a.filename}]\n\n${prefix}`;
images.push({ filename: a.filename, dataUrl: a.dataUrl });
}
ids.push(a.attachmentId);
}
return { prefix, ids, images };
};
return {
attachments,
attachWebpage,
attachImage,
removeAttachment,
clearAttachments,
processAttachments,
};
}
export type UseAttachmentsType = ReturnType<typeof useAttachments>;
@@ -0,0 +1,124 @@
import { useState, useRef } from 'react';
import { toast } from 'sonner';
export function useAudioRecording(onTranscription: (text: string) => void) {
const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const toggleRecording = async () => {
if (recording) {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
setRecording(false);
try {
if (recorder.state === 'inactive') {
recorder.stream.getTracks().forEach((t) => t.stop());
return;
}
const blob = await new Promise<Blob>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
recorder.onstop = () => {
clearTimeout(timeout);
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
chunksRef.current = [];
};
recorder.stop();
});
recorder.stream.getTracks().forEach((t) => t.stop());
if (blob.size === 0) {
toast.error('No audio was captured');
return;
}
setTranscribing(true);
try {
const wav = await blobToWav(blob);
const formData = new FormData();
formData.append('file', wav, 'recording.wav');
formData.append('temperature', '0.0');
formData.append('temperature_inc', '0.2');
formData.append('response_format', 'json');
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error);
const text = (json.text ?? '').trim();
if (text) onTranscription(text);
} finally {
setTranscribing(false);
}
} catch (err) {
recorder.stream?.getTracks().forEach((t) => t.stop());
chunksRef.current = [];
toast.error(err instanceof Error ? err.message : 'Recording failed');
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data);
};
recorder.start(250);
setRecording(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
}
};
return { recording, transcribing, toggleRecording };
}
export type UseAudioRecordingType = ReturnType<typeof useAudioRecording>;
// ── Helpers ──
const blobToWav = async (blob: Blob): Promise<Blob> => {
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
await ctx.close();
const samples = buf.getChannelData(0);
const len = samples.length;
const sr = buf.sampleRate;
const ab = new ArrayBuffer(44 + len * 2);
const v = new DataView(ab);
const s = (o: number, str: string) => {
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
};
s(0, 'RIFF');
v.setUint32(4, 36 + len * 2, true);
s(8, 'WAVE');
s(12, 'fmt ');
v.setUint32(16, 16, true);
v.setUint16(20, 1, true);
v.setUint16(22, 1, true);
v.setUint32(24, sr, true);
v.setUint32(28, sr * 2, true);
v.setUint16(32, 2, true);
v.setUint16(34, 16, true);
s(36, 'data');
v.setUint32(40, len * 2, true);
for (let i = 0; i < len; i++) {
const val = Math.max(-1, Math.min(1, samples[i]!));
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
}
return new Blob([ab], { type: 'audio/wav' });
};
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import Editor, { type OnMount } from '@monaco-editor/react'; import Editor, { type OnMount } from '@monaco-editor/react';
import type { editor as MonacoEditor } from 'monaco-editor'; import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFiles } from 'apps/FileBrowser'; import { useFilesAPI } from '../../hooks/useFilesAPI';
import { FileTree } from './FileTree'; import { FileTree } from './FileTree';
import { EditorTabs } from './EditorTabs'; import { EditorTabs } from './EditorTabs';
import { useEditorState } from './useEditorState'; import { useEditorState } from './useEditorState';
@@ -19,13 +19,12 @@ type CodeEditorViewProps = {
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => { export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } = const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
useEditorState(); useEditorState();
const { readFile, writeFile } = useFiles(root); const { readFile, writeFile } = useFilesAPI(root);
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
const activeFile = getActiveFile(); const activeFile = getActiveFile();
const handleOpenFile = useCallback( const handleOpenFile = async (path: string, name: string) => {
async (path: string, name: string) => {
const existing = files.find((f) => f.path === path); const existing = files.find((f) => f.path === path);
if (existing) { if (existing) {
setActivePath(path); setActivePath(path);
@@ -37,11 +36,9 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
} catch { } catch {
toast.error('Failed to read file'); toast.error('Failed to read file');
} }
}, };
[files, setActivePath, readFile, openFile],
);
const handleSave = useCallback(async () => { const handleSave = async () => {
if (!activeFile || !activeFile.isDirty) return; if (!activeFile || !activeFile.isDirty) return;
try { try {
await writeFile(activeFile.path, activeFile.content); await writeFile(activeFile.path, activeFile.content);
@@ -50,17 +47,14 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
} catch { } catch {
toast.error('Failed to save file'); toast.error('Failed to save file');
} }
}, [activeFile, writeFile, markSaved]); };
const handleEditorMount: OnMount = useCallback( const handleEditorMount: OnMount = (editor, monaco) => {
(editor, monaco) => {
editorRef.current = editor; editorRef.current = editor;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave(); handleSave();
}); });
}, };
[handleSave],
);
useEffect(() => { useEffect(() => {
const handler = (ev: KeyboardEvent) => { const handler = (ev: KeyboardEvent) => {
@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState'; import type { OpenFile } from './useEditorState';
@@ -12,7 +11,7 @@ type EditorTabsProps = {
}; };
const FileIcon = ({ name }: { name: string }) => { const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]); const svg = getIcon(name).svg;
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />; return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
}; };
@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback } from 'react'; import { useState } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react'; import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from 'apps/FileBrowser'; import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
type FileTreeProps = { type FileTreeProps = {
root: string; root: string;
@@ -18,7 +18,7 @@ type TreeNodeProps = {
}; };
const FileIcon = ({ name }: { name: string }) => { const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]); const svg = getIcon(name).svg;
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />; return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
}; };
@@ -32,11 +32,11 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<DirEntry[] | null>(null); const [children, setChildren] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { listDir } = useFiles(root); const { listDir } = useFilesAPI(root);
const isDir = entry.type === 'directory'; const isDir = entry.type === 'directory';
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`; const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
const handleClick = useCallback(async () => { const handleClick = async () => {
if (!isDir) { if (!isDir) {
onOpenFile(fullPath, entry.name); onOpenFile(fullPath, entry.name);
return; return;
@@ -52,7 +52,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
setLoading(false); setLoading(false);
} }
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]); };
return ( return (
<div> <div>
@@ -98,11 +98,11 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
}; };
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => { export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
const { listDir } = useFiles(root); const { listDir } = useFilesAPI(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null); const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const loadRoot = useCallback(async () => { const loadRoot = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await listDir(basePath); const res = await listDir(basePath);
@@ -111,7 +111,7 @@ export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
setEntries([]); setEntries([]);
} }
setLoading(false); setLoading(false);
}, [listDir, basePath]); };
if (entries === null && !loading) { if (entries === null && !loading) {
loadRoot(); loadRoot();
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState } from 'react';
export type OpenFile = { export type OpenFile = {
path: string; path: string;
@@ -12,17 +12,16 @@ export const useEditorState = () => {
const [files, setFiles] = useState<OpenFile[]>([]); const [files, setFiles] = useState<OpenFile[]>([]);
const [activePath, setActivePath] = useState<string | null>(null); const [activePath, setActivePath] = useState<string | null>(null);
const openFile = useCallback((path: string, name: string, content: string) => { const openFile = (path: string, name: string, content: string) => {
setFiles((prev) => { setFiles((prev) => {
const existing = prev.find((f) => f.path === path); const existing = prev.find((f) => f.path === path);
if (existing) return prev; if (existing) return prev;
return [...prev, { path, name, content, originalContent: content, isDirty: false }]; return [...prev, { path, name, content, originalContent: content, isDirty: false }];
}); });
setActivePath(path); setActivePath(path);
}, []); };
const closeFile = useCallback( const closeFile = (path: string) => {
(path: string) => {
setFiles((prev) => { setFiles((prev) => {
const next = prev.filter((f) => f.path !== path); const next = prev.filter((f) => f.path !== path);
if (activePath === path) { if (activePath === path) {
@@ -32,25 +31,23 @@ export const useEditorState = () => {
} }
return next; return next;
}); });
}, };
[activePath],
);
const setContent = useCallback((path: string, content: string) => { const setContent = (path: string, content: string) => {
setFiles((prev) => setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)), prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
); );
}, []); };
const markSaved = useCallback((path: string, content: string) => { const markSaved = (path: string, content: string) => {
setFiles((prev) => setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)), prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
); );
}, []); };
const getActiveFile = useCallback((): OpenFile | null => { const getActiveFile = (): OpenFile | null => {
return files.find((f) => f.path === activePath) ?? null; return files.find((f) => f.path === activePath) ?? null;
}, [files, activePath]); };
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile }; return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
}; };
@@ -0,0 +1,36 @@
import { Breadcrumb } from './components/Breadcrumb';
import { Toolbar } from './components/Toolbar';
import { HomeDirSelector } from './components/HomeDirSelector';
import { UploadProgress } from './components/UploadProgress';
import { FileViewContainer } from './components/FileViewContainer';
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
import { useFileBrowserApp } from './useFileBrowserApp';
type FileBrowserAppProps = {
basePath?: string;
};
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath);
return (
<div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} />
<HomeDirSelector fileBrowserManager={fileBrowserManager} basePath={basePath} />
{/* Breadcrumb */}
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={fileBrowserManager.handleNavigate} basePath={basePath} />
</div>
<UploadProgress fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
</div>
);
};
@@ -1,41 +1,16 @@
import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronUp, ChevronDown } from 'lucide-react'; import { ChevronUp, ChevronDown } from 'lucide-react';
import type { DirEntry } from './useFiles'; import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskSummary } from './useTasks'; import { getFileType } from '../../../FileViewer';
import { getFileType } from 'apps/FileViewer'; import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { FileItem } from './FileItem'; import { FileItem } from './FileItem';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type SortField = 'name' | 'size' | 'type' | 'date'; type SortField = 'name' | 'size' | 'type' | 'date';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
type FileGridProps = { type FileGridProps = {
entries: DirEntry[]; fileBrowserManager: UseFileBrowserAppType;
viewMode: 'grid' | 'list';
currentPath: string;
selected: Set<string>;
clipboard: ClipboardState;
onOpen: (entry: DirEntry) => void;
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (names: Set<string>) => void;
onCut: () => void;
onCopy: () => void;
renamingName: string | null;
onRenamingChange: (name: string | null) => void;
onReadAloud: (entry: DirEntry) => void;
onOcr: (entry: DirEntry) => void;
onTranscribe: (entry: DirEntry) => void;
onExtractAudio: (entry: DirEntry) => void;
onExtract: (entry: DirEntry) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
scrollRef: React.RefObject<HTMLDivElement | null>;
}; };
const LIST_ROW_HEIGHT = 42; const LIST_ROW_HEIGHT = 42;
@@ -67,37 +42,38 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
return cols; return cols;
}; };
export const FileGrid = ({ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
entries, const {
visibleEntries: entries,
viewMode, viewMode,
currentPath, currentPath,
selected, selected,
clipboard, clipboard,
onOpen, handleOpen,
onDelete, handleDelete,
onRename, handleRename,
onChat, handleChat,
onDownload, handleDownload,
onSelect, setSelected,
onCut, handleCut,
onCopy, handleCopy,
renamingName, renamingName,
onRenamingChange, setRenamingName,
onReadAloud, handleReadAloud,
onOcr, handleOcr,
onTranscribe, handleTranscribe,
onExtractAudio, handleExtractAudio,
onExtract, handleExtract,
getMatchingTasks, getMatchingTasks,
onRunTask, handleRunTask,
onCreateWorkspace, handleCreateWorkspace,
scrollRef, fileScrollRef: scrollRef,
}: FileGridProps) => { } = fileBrowserManager;
const lastClickedIdx = useRef<number>(-1); const lastClickedIdx = useRef<number>(-1);
const [sortField, setSortField] = useState<SortField>('name'); const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const sorted = useMemo(() => { const sorted = (() => {
const compare = (a: DirEntry, b: DirEntry): number => { const compare = (a: DirEntry, b: DirEntry): number => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
let result: number; let result: number;
@@ -122,7 +98,7 @@ export const FileGrid = ({
return sortDirection === 'asc' ? result : -result; return sortDirection === 'asc' ? result : -result;
}; };
return [...entries].sort(compare); return [...entries].sort(compare);
}, [entries, sortField, sortDirection]); })();
const toggleSort = (field: SortField) => { const toggleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
@@ -133,8 +109,7 @@ export const FileGrid = ({
} }
}; };
const handleSelect = useCallback( const handleSelect = (entry: DirEntry, ev: React.MouseEvent) => {
(entry: DirEntry, ev: React.MouseEvent) => {
const idx = sorted.findIndex((e) => e.name === entry.name); const idx = sorted.findIndex((e) => e.name === entry.name);
if (ev.shiftKey && lastClickedIdx.current >= 0) { if (ev.shiftKey && lastClickedIdx.current >= 0) {
@@ -144,7 +119,7 @@ export const FileGrid = ({
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
next.add(sorted[i]!.name); next.add(sorted[i]!.name);
} }
onSelect(next); setSelected(next);
} else if (ev.ctrlKey || ev.metaKey) { } else if (ev.ctrlKey || ev.metaKey) {
const next = new Set(selected); const next = new Set(selected);
if (next.has(entry.name)) { if (next.has(entry.name)) {
@@ -152,15 +127,13 @@ export const FileGrid = ({
} else { } else {
next.add(entry.name); next.add(entry.name);
} }
onSelect(next); setSelected(next);
lastClickedIdx.current = idx; lastClickedIdx.current = idx;
} else { } else {
onSelect(new Set([entry.name])); setSelected(new Set([entry.name]));
lastClickedIdx.current = idx; lastClickedIdx.current = idx;
} }
}, };
[sorted, selected, onSelect],
);
const cols = useColumnCount(scrollRef); const cols = useColumnCount(scrollRef);
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>(); const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
@@ -190,24 +163,24 @@ export const FileGrid = ({
anySelected={anySelected} anySelected={anySelected}
selectedCount={selected.size} selectedCount={selected.size}
isCut={cutPaths.has(entryPath(entry.name))} isCut={cutPaths.has(entryPath(entry.name))}
onOpen={onOpen} onOpen={handleOpen}
onDelete={onDelete} onDelete={handleDelete}
onRename={onRename} onRename={handleRename}
onChat={onChat} onChat={handleChat}
onDownload={onDownload} onDownload={handleDownload}
onSelect={handleSelect} onSelect={handleSelect}
onCut={onCut} onCut={handleCut}
onCopy={onCopy} onCopy={handleCopy}
forceRename={renamingName === entry.name} forceRename={renamingName === entry.name}
onRenamingChange={onRenamingChange} onRenamingChange={setRenamingName}
onReadAloud={onReadAloud} onReadAloud={handleReadAloud}
onOcr={onOcr} onOcr={handleOcr}
onTranscribe={onTranscribe} onTranscribe={handleTranscribe}
onExtractAudio={onExtractAudio} onExtractAudio={handleExtractAudio}
onExtract={onExtract} onExtract={handleExtract}
matchingTasks={getMatchingTasks(entry.name, entry.type)} matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask} onRunTask={handleRunTask}
onCreateWorkspace={onCreateWorkspace} onCreateWorkspace={handleCreateWorkspace}
/> />
); );
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react'; import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { import {
@@ -22,9 +22,9 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu'; } from '@/components/ui/context-menu';
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import type { DirEntry } from './useFiles'; import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskSummary } from './useTasks'; import type { TaskSummary } from '../../useTasks';
import { getFileType } from 'apps/FileViewer'; import { getFileType } from '../../../FileViewer';
export type FileItemProps = { export type FileItemProps = {
entry: DirEntry; entry: DirEntry;
@@ -338,7 +338,7 @@ const InlineRenameInput = ({
}) => { }) => {
const [value, setValue] = useState(initialName); const [value, setValue] = useState(initialName);
const mountRef = useCallback((node: HTMLInputElement | null) => { const mountRef = (node: HTMLInputElement | null) => {
if (!node) return; if (!node) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
node.focus(); node.focus();
@@ -349,7 +349,7 @@ const InlineRenameInput = ({
node.select(); node.select();
} }
}); });
}, []); };
const commit = () => { const commit = () => {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -399,7 +399,7 @@ const Checkbox = ({
); );
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => { const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]); const svg = getIcon(name).svg;
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />; return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
}; };
@@ -0,0 +1,126 @@
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { FileGrid } from './FileGrid';
type FileViewContainerProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps) => {
const {
searchQuery,
searching,
searchResults,
handleSearchResultClick,
dragging,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
fileScrollRef,
handleBackgroundClick,
loading,
handlePaste,
handleCreateDir,
handleCreateWorkspaceHere,
setShowVideoDownload,
} = fileBrowserManager;
return (
<div
className="flex-1 min-h-0 relative"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-duck-teal">
<Upload className="h-8 w-8" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
</div>
)}
{searchQuery.trim() ? (
<div className="h-full overflow-auto p-4">
{searching ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
<div className="flex flex-col">
{searchResults.map((entry) => {
const isDir = entry.type === 'directory';
return (
<div
key={entry.path}
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
onClick={() => handleSearchResultClick(entry)}
>
{isDir ? (
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
) : (
<span
className="inline-flex h-5 w-5 shrink-0"
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
/>
)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
</div>
</div>
);
})}
</div>
) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
No results found
</div>
) : null}
</div>
) : (
<ContextMenu>
<ContextMenuTrigger asChild>
<div ref={fileScrollRef} className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : (
<FileGrid fileBrowserManager={fileBrowserManager} />
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const name = prompt('Folder name');
if (name?.trim()) handleCreateDir(name.trim());
}}
className="cursor-pointer"
>
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</div>
);
};
@@ -0,0 +1,42 @@
import { Home, FolderRoot, Code } from 'lucide-react';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
type HomeDirSelectorProps = {
fileBrowserManager: UseFileBrowserAppType;
basePath: string;
};
export const HomeDirSelector = ({ fileBrowserManager, basePath }: HomeDirSelectorProps) => {
const { user, homeRoot, setHomeRoot, currentPath, setCurrentPath } = fileBrowserManager;
if (basePath !== '/' || user?.role !== 'Super Admin') return null;
return (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<RadioGroup
value={homeRoot}
onValueChange={(v) => {
setHomeRoot(v as 'home' | '~' | 'officer.dev');
if (currentPath !== '/') setCurrentPath('/');
}}
className="flex items-center gap-4"
>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="home" />
<Home className="h-3.5 w-3.5" />
Home dir
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="~" />
<FolderRoot className="h-3.5 w-3.5" />~
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="officer.dev" />
<Code className="h-3.5 w-3.5" />
officer.dev
</label>
</RadioGroup>
</div>
);
};
@@ -0,0 +1,28 @@
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { TaskRunnerModal } from './TaskRunnerModal';
type TaskRunnerDialogProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) => {
const { runningTask, setRunningTask, refresh, homeRoot, currentPath } = fileBrowserManager;
if (!runningTask) return null;
return (
<TaskRunnerModal
open
onOpenChange={(open) => {
if (!open) {
setRunningTask(null);
refresh();
}
}}
task={runningTask.task}
entryName={runningTask.entry.name}
entryType={runningTask.entry.type}
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
/>
);
};
@@ -3,11 +3,10 @@ import { X } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import type { TaskInfo } from 'apps/Chat'; import type { TaskInfo } from '../../../Chat';
import { usePi, EmbeddableChat } from 'apps/Chat'; import { usePiChat, EmbeddableChat } from '../../../Chat';
import { useVisiblePiModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings'; import { useSettings } from '@/state/useSettings';
import type { TaskSummary } from './useTasks'; import type { TaskSummary } from '../../useTasks';
const playDing = () => { const playDing = () => {
const ctx = new AudioContext(); const ctx = new AudioContext();
@@ -46,8 +45,7 @@ const PiMonoInner = ({
initialModel, initialModel,
taskInfo, taskInfo,
}: PiMonoInnerProps) => { }: PiMonoInnerProps) => {
const chat = usePi(undefined, initialModel, { replaceUrl: false, taskInfo }); const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
const models = useVisiblePiModels();
const wasGenerating = useRef(false); const wasGenerating = useRef(false);
useEffect(() => { useEffect(() => {
@@ -58,7 +56,6 @@ const PiMonoInner = ({
return ( return (
<EmbeddableChat <EmbeddableChat
chat={chat} chat={chat}
availableModels={models}
defaultInput={defaultInput} defaultInput={defaultInput}
cwd={cwd} cwd={cwd}
className="flex-1 min-h-0" className="flex-1 min-h-0"
@@ -0,0 +1,29 @@
import { RefreshCw } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
import { SelectionActions } from './SelectionActions';
import { DefaultActions } from './DefaultActions';
type ActionButtonsProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const ActionButtons = ({ fileBrowserManager }: ActionButtonsProps) => {
const { refresh, selected } = fileBrowserManager;
return (
<div className="flex items-center gap-1">
<button
onClick={refresh}
title="Refresh"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<RefreshCw className="h-4 w-4" />
</button>
{selected.size > 0 ? (
<SelectionActions fileBrowserManager={fileBrowserManager} />
) : (
<DefaultActions fileBrowserManager={fileBrowserManager} />
)}
</div>
);
};
@@ -0,0 +1,71 @@
import { useRef } from 'react';
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
type DefaultActionsProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
const { handleCreateDir, handleUpload, clipboard, handlePaste } = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
const handleFileChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.files?.length) {
handleUpload(ev.target.files);
ev.target.value = '';
}
};
return (
<>
<button
onClick={() => {
const name = prompt('Folder name');
if (name?.trim()) handleCreateDir(name.trim());
}}
title="New folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderPlus className="h-4 w-4" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
title="Upload files"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Upload className="h-4 w-4" />
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
</button>
<button
onClick={() => folderInputRef.current?.click()}
title="Upload folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderUp className="h-4 w-4" />
<input
ref={(input) => {
folderInputRef.current = input;
if (input) input.setAttribute('webkitdirectory', '');
}}
type="file"
className="hidden"
onChange={handleFileChange}
/>
</button>
{clipboard !== null && (
<button
onClick={handlePaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
</>
);
};
@@ -0,0 +1,64 @@
import { Scissors, Copy, ClipboardPaste, Download, Trash2, X } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
type SelectionActionsProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => {
const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } =
fileBrowserManager;
return (
<>
<span className="text-sm font-medium text-duck-dark/70 mr-1">
{selected.size}
<span className="hidden md:inline"> selected</span>
</span>
<button
onClick={handleCut}
title="Cut"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Scissors className="h-4 w-4" />
</button>
<button
onClick={handleCopy}
title="Copy"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Copy className="h-4 w-4" />
</button>
{clipboard !== null && (
<button
onClick={handlePaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
<button
onClick={handleDownloadSelected}
title="Download"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={handleDeleteSelected}
title="Delete"
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={() => setSelected(new Set())}
title="Clear selection"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<X className="h-4 w-4" />
</button>
</>
);
};
@@ -0,0 +1,129 @@
import { Loader2, LayoutGrid, List, Search, X, Check, GitBranch, Eye, EyeOff } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
import { ActionButtons } from './ActionButtons';
type ToolbarProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
const {
showCloneInput,
setShowCloneInput,
cloneUrl,
setCloneUrl,
cloning,
handleGitClone,
searchQuery,
setSearchQuery,
searchInputRef,
showHidden,
setShowHidden,
viewMode,
setViewMode,
} = fileBrowserManager;
return (
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
<ActionButtons fileBrowserManager={fileBrowserManager} />
<div className="flex-1" />
{showCloneInput ? (
<form
onSubmit={(ev) => {
ev.preventDefault();
handleGitClone();
}}
className="hidden md:flex items-center gap-2"
>
<input
autoFocus
value={cloneUrl}
onChange={(ev) => setCloneUrl(ev.target.value)}
placeholder="https://github.com/user/repo.git"
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
disabled={cloning}
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
setShowCloneInput(false);
setCloneUrl('');
}
}}
/>
<button
type="submit"
disabled={cloning}
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
title="Clone"
>
{cloning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</button>
<button
type="button"
disabled={cloning}
onClick={() => {
setShowCloneInput(false);
setCloneUrl('');
}}
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<button
onClick={() => setShowCloneInput(true)}
title="Git clone"
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<GitBranch className="h-4 w-4" />
</button>
)}
<div className="relative flex items-center">
<Search className="absolute left-2 h-4 w-4 text-duck-dark/40 pointer-events-none" />
<input
ref={searchInputRef}
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
setSearchQuery('');
searchInputRef.current?.blur();
}
}}
placeholder="Search files..."
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-1.5 p-0.5 rounded text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
onClick={() => setShowHidden((v) => !v)}
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
>
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'grid' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'list' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<List className="h-4 w-4" />
</button>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from './Toolbar';
@@ -0,0 +1,26 @@
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
type UploadProgressProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
const { uploadProgress } = fileBrowserManager;
if (uploadProgress === null) return null;
return (
<div className="shrink-0 px-4 pt-3">
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
<div
className="h-full rounded-full bg-duck-teal transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
type VideoDownloadDialogProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
const { showVideoDownload, setShowVideoDownload, videoUrl, setVideoUrl, audioOnly, setAudioOnly, handleVideoDownload } =
fileBrowserManager;
const handleClose = () => {
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
};
return (
<Dialog
open={showVideoDownload}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Download video</DialogTitle>
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
</DialogHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
handleVideoDownload();
}}
className="flex flex-col gap-4"
>
<input
autoFocus
value={videoUrl}
onChange={(ev) => setVideoUrl(ev.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="h-10 w-full text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-3"
/>
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
Extract audio only (mp3)
</label>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!videoUrl.trim()}
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Download
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,5 @@
export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { FileGrid } from './FileGrid';
export { FileItem, type FileItemProps } from './FileItem';
export { TaskRunnerModal } from './TaskRunnerModal';
@@ -0,0 +1 @@
export * from './FileBrowserApp';
@@ -1,51 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react'; 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 { import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
Loader2, import { useTasks, type TaskSummary } from '../useTasks';
LayoutGrid,
List,
ClipboardPaste,
FolderPlus,
Search,
X,
Check,
Folder,
GitBranch,
Home,
FolderRoot,
Code,
Eye,
EyeOff,
Upload,
Download,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { useFiles, type DirEntry } from './useFiles';
import { useTasks, type TaskSummary } from './useTasks';
import { Breadcrumb } from './Breadcrumb';
import { Toolbar } from './Toolbar';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState'; import { useUserState } from '@/state/useUserState';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { FileGrid } from './FileGrid';
import { TaskRunnerModal } from './TaskRunnerModal';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; export const useFileBrowserApp = (basePath: string) => {
type HomeRoot = 'home' | '~' | 'officer.dev';
type FilesProps = {
basePath?: string;
};
export const Files = ({ basePath = '/' }: FilesProps) => {
const { user } = useAuth(); const { user } = useAuth();
const client = useClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home'); const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
@@ -53,7 +15,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const [entries, setEntries] = useState<DirEntry[]>([]); const [entries, setEntries] = useState<DirEntry[]>([]);
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);
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());
@@ -76,14 +37,17 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const searchInputRef = useRef<HTMLInputElement | null>(null); const searchInputRef = useRef<HTMLInputElement | null>(null);
const fileScrollRef = useRef<HTMLDivElement | null>(null); const fileScrollRef = useRef<HTMLDivElement | null>(null);
const viewPath = searchParams.get('view'); const viewPath = searchParams.get('view');
const files = useFiles(homeRoot); const files = useFilesAPI(homeRoot);
const filesRef = useRef(files); const filesRef = useRef(files);
filesRef.current = files; filesRef.current = files;
const currentPathRef = useRef(currentPath); const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath; currentPathRef.current = currentPath;
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.')); const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
const refresh = useCallback(async () => { const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const selectedPaths = () => Array.from(selected).map(entryPath);
const refresh = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await filesRef.current.listDir(currentPathRef.current); const data = await filesRef.current.listDir(currentPathRef.current);
@@ -98,7 +62,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); };
useEffect(() => { useEffect(() => {
if (basePath !== '/' && !currentPath.startsWith(basePath)) { if (basePath !== '/' && !currentPath.startsWith(basePath)) {
@@ -108,7 +72,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
refresh(); refresh();
}, [currentPath, homeRoot]); }, [currentPath, homeRoot]);
// Clear selection when navigating
useEffect(() => { useEffect(() => {
setSelected(new Set()); setSelected(new Set());
}, [currentPath]); }, [currentPath]);
@@ -139,6 +102,121 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}; };
}, [searchQuery]); }, [searchQuery]);
// Clipboard image paste
useEffect(() => {
const handler = (ev: ClipboardEvent) => {
const tag = (ev.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const items = ev.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length === 0) return;
ev.preventDefault();
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const dt = new DataTransfer();
imageFiles.forEach((file, i) => {
const ext = file.type.split('/')[1] ?? 'png';
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
dt.items.add(new File([file], name, { type: file.type }));
});
handleUpload(dt.files);
};
window.addEventListener('paste', handler);
return () => window.removeEventListener('paste', handler);
}, [currentPath, homeRoot]);
// Keyboard shortcuts
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
const tag = (ev.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (ev.key === 'Escape') {
if (viewPath) return;
if (searchQuery) {
setSearchQuery('');
searchInputRef.current?.blur();
return;
}
setSelected(new Set());
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'a') {
ev.preventDefault();
setSelected(new Set(visibleEntries.map((e) => e.name)));
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'c') {
ev.preventDefault();
if (selected.size > 0) {
const paths = selectedPaths();
setClipboard({ paths, mode: 'copy' });
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
}
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'x') {
ev.preventDefault();
if (selected.size > 0) {
const paths = selectedPaths();
setClipboard({ paths, mode: 'cut' });
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
}
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
if (clipboard) {
ev.preventDefault();
handlePaste();
}
return;
}
if (ev.key === 'Delete' || ev.key === 'Backspace') {
if (selected.size > 0) {
ev.preventDefault();
handleDeleteSelected();
}
return;
}
if (ev.key === 'F2') {
if (selected.size === 1) {
ev.preventDefault();
setRenamingName(Array.from(selected)[0]!);
}
return;
}
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
// ── Handlers ──
const handleSearchResultClick = (entry: DirEntry) => { const handleSearchResultClick = (entry: DirEntry) => {
if (!entry.path) return; if (!entry.path) return;
if (entry.type === 'directory') { if (entry.type === 'directory') {
@@ -151,10 +229,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
setSearchQuery(''); setSearchQuery('');
}; };
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const selectedPaths = () => Array.from(selected).map(entryPath);
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
if (basePath !== '/' && !path.startsWith(basePath)) { if (basePath !== '/' && !path.startsWith(basePath)) {
setCurrentPath(basePath); setCurrentPath(basePath);
@@ -211,7 +285,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}; };
const handleDelete = async (entry: DirEntry) => { const handleDelete = async (entry: DirEntry) => {
// If the entry is part of a multi-selection, delete all selected
if (selected.has(entry.name) && selected.size > 1) { if (selected.has(entry.name) && selected.size > 1) {
handleDeleteSelected(); handleDeleteSelected();
return; return;
@@ -301,7 +374,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Generating speech audio...'); const toastId = toast.loading('Generating speech audio...');
try { try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root: homeRoot }); const { audioPath, audioRoot } = await files.tts(filePath);
toast.dismiss(toastId); toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot }); setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
} catch { } catch {
@@ -313,7 +386,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting text from image...'); const toastId = toast.loading('Extracting text from image...');
try { try {
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root: homeRoot }); const { ocrPath, ocrRoot } = await files.ocr(filePath);
toast.dismiss(toastId); toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot }); setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot });
} catch { } catch {
@@ -325,7 +398,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Transcribing audio...'); const toastId = toast.loading('Transcribing audio...');
try { try {
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root: homeRoot }); const { transcriptionPath, transcriptionRoot } = await files.transcribe(filePath);
toast.dismiss(toastId); toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot }); setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot });
} catch { } catch {
@@ -337,7 +410,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting audio from video...'); const toastId = toast.loading('Extracting audio from video...');
try { try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root: homeRoot }); const { audioPath, audioRoot } = await files.extractAudio(filePath);
toast.dismiss(toastId); toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot }); setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
} catch { } catch {
@@ -349,7 +422,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting archive...'); const toastId = toast.loading('Extracting archive...');
try { try {
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root: homeRoot }); const { extractedPath } = await files.extract(filePath);
const folderName = extractedPath.split('/').pop() ?? extractedPath; const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`, { id: toastId }); toast.success(`Extracted to "${folderName}"`, { id: toastId });
await refresh(); await refresh();
@@ -406,7 +479,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}; };
const handlePaste = async () => { const handlePaste = async () => {
// Internal file clipboard takes priority
if (clipboard) { if (clipboard) {
try { try {
if (clipboard.mode === 'copy') { if (clipboard.mode === 'copy') {
@@ -423,7 +495,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
return; return;
} }
// Try system clipboard for images/files
try { try {
const clipboardItems = await navigator.clipboard.read(); const clipboardItems = await navigator.clipboard.read();
const imageFiles: File[] = []; const imageFiles: File[] = [];
@@ -445,125 +516,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
await handleUpload(dt.files); await handleUpload(dt.files);
} }
} catch { } catch {
// Clipboard API not available or permission denied — silently ignore // Clipboard API not available or permission denied
} }
}; };
// Clipboard image paste
useEffect(() => {
const handler = (ev: ClipboardEvent) => {
const tag = (ev.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const items = ev.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length === 0) return;
ev.preventDefault();
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const dt = new DataTransfer();
imageFiles.forEach((file, i) => {
const ext = file.type.split('/')[1] ?? 'png';
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
dt.items.add(new File([file], name, { type: file.type }));
});
handleUpload(dt.files);
};
window.addEventListener('paste', handler);
return () => window.removeEventListener('paste', handler);
}, [currentPath, homeRoot]);
// Keyboard shortcuts
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
// Don't capture when typing in inputs
const tag = (ev.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (ev.key === 'Escape') {
if (viewPath) return; // Let the viewer handle it
if (searchQuery) {
setSearchQuery('');
searchInputRef.current?.blur();
return;
}
setSelected(new Set());
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'a') {
ev.preventDefault();
setSelected(new Set(visibleEntries.map((e) => e.name)));
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'c') {
ev.preventDefault();
if (selected.size > 0) {
const paths = selectedPaths();
setClipboard({ paths, mode: 'copy' });
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
}
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'x') {
ev.preventDefault();
if (selected.size > 0) {
const paths = selectedPaths();
setClipboard({ paths, mode: 'cut' });
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
}
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
if (clipboard) {
ev.preventDefault();
handlePaste();
}
return;
}
if (ev.key === 'Delete' || ev.key === 'Backspace') {
if (selected.size > 0) {
ev.preventDefault();
handleDeleteSelected();
}
return;
}
if (ev.key === 'F2') {
if (selected.size === 1) {
ev.preventDefault();
setRenamingName(Array.from(selected)[0]!);
}
return;
}
// Type-to-search: focus search bar on printable character
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
const handleDragEnter = (ev: React.DragEvent) => { const handleDragEnter = (ev: React.DragEvent) => {
ev.preventDefault(); ev.preventDefault();
dragCounter.current++; dragCounter.current++;
@@ -592,22 +548,19 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const items = ev.dataTransfer.items; const items = ev.dataTransfer.items;
if (!items || items.length === 0) return; if (!items || items.length === 0) return;
// Collect FileSystemEntry objects (supports folders) const fsEntries: FileSystemEntry[] = [];
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const entry = items[i]!.webkitGetAsEntry?.(); const entry = items[i]!.webkitGetAsEntry?.();
if (entry) entries.push(entry); if (entry) fsEntries.push(entry);
} }
// If no entries (browser doesn't support webkitGetAsEntry), fall back to files if (fsEntries.length === 0) {
if (entries.length === 0) {
if (ev.dataTransfer.files.length > 0) { if (ev.dataTransfer.files.length > 0) {
handleUpload(ev.dataTransfer.files); handleUpload(ev.dataTransfer.files);
} }
return; return;
} }
// Recursively read all files from entries
const allFiles: File[] = []; const allFiles: File[] = [];
const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => { const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => {
@@ -631,7 +584,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
for (const child of batch) { for (const child of batch) {
await readEntry(child, path + entry.name + '/'); await readEntry(child, path + entry.name + '/');
} }
readBatch(); // readEntries may not return all at once readBatch();
}); });
}; };
readBatch(); readBatch();
@@ -640,7 +593,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
return Promise.resolve(); return Promise.resolve();
}; };
for (const entry of entries) { for (const entry of fsEntries) {
await readEntry(entry, ''); await readEntry(entry, '');
} }
@@ -654,367 +607,79 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
setSelected(new Set()); setSelected(new Set());
}; };
return ( return {
<> // Auth
<div className="flex flex-col h-full overflow-hidden"> user,
{/* Toolbar */} // Navigation
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden"> homeRoot, setHomeRoot,
<Toolbar currentPath, setCurrentPath,
onRefresh={refresh} // Directory listing
onCreateDir={handleCreateDir} visibleEntries, loading, refresh,
onUpload={handleUpload} // View
selectionCount={selected.size} viewMode, setViewMode,
hasClipboard={clipboard !== null} showHidden, setShowHidden,
onCut={handleCut} // Selection
onCopy={handleCopy} selected, setSelected,
onPaste={handlePaste} clipboard,
onDownloadSelected={handleDownloadSelected} renamingName, setRenamingName,
onDeleteSelected={handleDeleteSelected} // Search
onClearSelection={() => setSelected(new Set())} searchQuery, setSearchQuery,
/> searchResults, searching,
<div className="flex-1" /> searchInputRef,
{showCloneInput ? ( // Upload
<form uploadProgress,
onSubmit={(ev) => { // Clone
ev.preventDefault(); showCloneInput, setShowCloneInput,
handleGitClone(); cloneUrl, setCloneUrl,
}} cloning,
className="hidden md:flex items-center gap-2" // Drag & drop
> dragging,
<input // Task runner
autoFocus runningTask, setRunningTask,
value={cloneUrl} getMatchingTasks,
onChange={(ev) => setCloneUrl(ev.target.value)} // Video download
placeholder="https://github.com/user/repo.git" showVideoDownload, setShowVideoDownload,
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2" videoUrl, setVideoUrl,
disabled={cloning} audioOnly, setAudioOnly,
onKeyDown={(ev) => { // Refs
if (ev.key === 'Escape') { fileScrollRef,
setShowCloneInput(false); // Handlers
setCloneUrl(''); handleSearchResultClick,
} handleNavigate,
}} handleOpen,
/> handleCreateDir,
<button handleUpload,
type="submit" handleRename,
disabled={cloning} handleDelete,
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40" handleDeleteSelected,
title="Clone" handleChat,
> handleDownload,
{cloning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />} handleDownloadSelected,
</button> handleRunTask,
<button handleCreateWorkspace,
type="button" handleCreateWorkspaceHere,
disabled={cloning} handleReadAloud,
onClick={() => { handleOcr,
setShowCloneInput(false); handleTranscribe,
setCloneUrl(''); handleExtractAudio,
}} handleExtract,
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40" handleGitClone,
title="Cancel" handleVideoDownload,
> handleCut,
<X className="h-4 w-4" /> handleCopy,
</button> handlePaste,
</form> handleDragEnter,
) : ( handleDragLeave,
<button handleDragOver,
onClick={() => setShowCloneInput(true)} handleDrop,
title="Git clone" handleBackgroundClick,
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<GitBranch className="h-4 w-4" />
</button>
)}
<div className="relative flex items-center">
<Search className="absolute left-2 h-4 w-4 text-duck-dark/40 pointer-events-none" />
<input
ref={searchInputRef}
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
setSearchQuery('');
searchInputRef.current?.blur();
}
}}
placeholder="Search files..."
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-1.5 p-0.5 rounded text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
onClick={() => setShowHidden((v) => !v)}
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
>
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'grid' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'list' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<List className="h-4 w-4" />
</button>
</div>
</div>
{/* Home dir selector (Super Admin only) */}
{basePath === '/' && user?.role === 'Super Admin' && (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<RadioGroup
value={homeRoot}
onValueChange={(v) => {
setHomeRoot(v as HomeRoot);
if (currentPath !== '/') setCurrentPath('/');
}}
className="flex items-center gap-4"
>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="home" />
<Home className="h-3.5 w-3.5" />
Home dir
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="~" />
<FolderRoot className="h-3.5 w-3.5" />~
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
<RadioGroupItem value="officer.dev" />
<Code className="h-3.5 w-3.5" />
officer.dev
</label>
</RadioGroup>
</div>
)}
{/* Breadcrumb */}
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<Breadcrumb path={currentPath} onNavigate={handleNavigate} basePath={basePath} />
</div>
{/* Upload progress */}
{uploadProgress !== null && (
<div className="shrink-0 px-4 pt-3">
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
<div
className="h-full rounded-full bg-duck-teal transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* File content */}
<div
className="flex-1 min-h-0 relative"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-duck-teal">
<Upload className="h-8 w-8" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
</div>
)}
{searchQuery.trim() ? (
<div className="h-full overflow-auto p-4">
{searching ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
<div className="flex flex-col">
{searchResults.map((entry) => {
const isDir = entry.type === 'directory';
return (
<div
key={entry.path}
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
onClick={() => handleSearchResultClick(entry)}
>
{isDir ? (
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
) : (
<span
className="inline-flex h-5 w-5 shrink-0"
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
/>
)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
</div>
</div>
);
})}
</div>
) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
No results found
</div>
) : null}
</div>
) : (
<ContextMenu>
<ContextMenuTrigger asChild>
<div ref={fileScrollRef} className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : (
<FileGrid
entries={visibleEntries}
viewMode={viewMode}
currentPath={currentPath}
selected={selected}
clipboard={clipboard}
onOpen={handleOpen}
onDelete={handleDelete}
onRename={handleRename}
onChat={handleChat}
onDownload={handleDownload}
onSelect={setSelected}
onCut={handleCut}
onCopy={handleCopy}
renamingName={renamingName}
onRenamingChange={setRenamingName}
onReadAloud={handleReadAloud}
onOcr={handleOcr}
onTranscribe={handleTranscribe}
onExtractAudio={handleExtractAudio}
onExtract={handleExtract}
getMatchingTasks={getMatchingTasks}
onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
scrollRef={fileScrollRef}
/>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const name = prompt('Folder name');
if (name?.trim()) handleCreateDir(name.trim());
}}
className="cursor-pointer"
>
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</div>
</div>
{runningTask && (
<TaskRunnerModal
open
onOpenChange={(open) => {
if (!open) {
setRunningTask(null);
refresh();
}
}}
task={runningTask.task}
entryName={runningTask.entry.name}
entryType={runningTask.entry.type}
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
/>
)}
<Dialog
open={showVideoDownload}
onOpenChange={(open) => {
if (!open) {
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Download video</DialogTitle>
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
</DialogHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
handleVideoDownload();
}}
className="flex flex-col gap-4"
>
<input
autoFocus
value={videoUrl}
onChange={(ev) => setVideoUrl(ev.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="h-10 w-full text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-3"
/>
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
Extract audio only (mp3)
</label>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
}}
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!videoUrl.trim()}
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Download
</button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
}; };
};
export type UseFileBrowserAppType = ReturnType<typeof useFileBrowserApp>;
// ── Types ──
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type HomeRoot = 'home' | '~' | 'officer.dev';
@@ -0,0 +1,29 @@
import { Widget } from 'widgets/Widget';
import { useFileBrowserWidget } from './useFileBrowserWidget';
import { Header } from './components/Header';
import { SearchResults } from './components/SearchResults';
import { BrowseTab } from './components/BrowseTab';
import { RecentTab } from './components/RecentTab';
import { PinnedTab } from './components/PinnedTab';
export const FileBrowserWidget = () => {
const fileBrowserManager = useFileBrowserWidget();
const { tab, isSearching } = fileBrowserManager;
return (
<Widget title="File Browser">
<Header fileBrowserManager={fileBrowserManager} />
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
<SearchResults fileBrowserManager={fileBrowserManager} />
) : (
<>
{tab === 'browse' && <BrowseTab fileBrowserManager={fileBrowserManager} />}
{tab === 'recent' && <RecentTab fileBrowserManager={fileBrowserManager} />}
{tab === 'pinned' && <PinnedTab fileBrowserManager={fileBrowserManager} />}
</>
)}
</div>
</Widget>
);
};
@@ -0,0 +1,46 @@
import { Loader2 } from 'lucide-react';
import { Breadcrumb } from '../../FileBrowserApp/components/Breadcrumb';
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
import { EntryRow } from './EntryRow';
type BrowseTabProps = {
fileBrowserManager: UseFileBrowserWidgetType;
};
export const BrowseTab = ({ fileBrowserManager }: BrowseTabProps) => {
const { browsePath, setBrowsePath, entries, loading, openFile, isPinned, togglePin } = fileBrowserManager;
return (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : entries.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{entries.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
);
};
@@ -0,0 +1,47 @@
import { Folder, Pin, PinOff } from 'lucide-react';
import { getIcon } from 'material-file-icons';
type EntryRowProps = {
name: string;
subtitle?: string;
type: 'file' | 'directory';
pinned?: boolean;
onPin?: () => void;
onClick: () => void;
};
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = getIcon(name).svg;
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
{type === 'directory' ? (
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
) : (
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{name}</span>
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
</div>
</button>
{onPin && (
<button
onClick={(ev) => {
ev.stopPropagation();
onPin();
}}
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
pinned
? 'text-duck-teal hover:text-duck-teal/70'
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
}`}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</li>
);
@@ -0,0 +1,55 @@
import { FolderOpen, Clock, Pin, Search, X } from 'lucide-react';
import type { Tab, UseFileBrowserWidgetType } from '../useFileBrowserWidget';
const TABS: { key: Tab; label: string; icon: typeof FolderOpen }[] = [
{ key: 'browse', label: 'Browse', icon: FolderOpen },
{ key: 'recent', label: 'Recent', icon: Clock },
{ key: 'pinned', label: 'Pinned', icon: Pin },
];
type HeaderProps = {
fileBrowserManager: UseFileBrowserWidgetType;
};
export const Header = ({ fileBrowserManager }: HeaderProps) => {
const { tab, setTab, isSearching, searchQuery, setSearchQuery } = fileBrowserManager;
return (
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,30 @@
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
import { EntryRow } from './EntryRow';
type PinnedTabProps = {
fileBrowserManager: UseFileBrowserWidgetType;
};
export const PinnedTab = ({ fileBrowserManager }: PinnedTabProps) => {
const { pinned, openFile, togglePin } = fileBrowserManager;
if (pinned.length === 0) {
return <p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>;
}
return (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
);
};
@@ -0,0 +1,30 @@
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
import { EntryRow } from './EntryRow';
type RecentTabProps = {
fileBrowserManager: UseFileBrowserWidgetType;
};
export const RecentTab = ({ fileBrowserManager }: RecentTabProps) => {
const { recents, openFile, isPinned, togglePin } = fileBrowserManager;
if (recents.length === 0) {
return <p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>;
}
return (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
);
};
@@ -0,0 +1,41 @@
import { Loader2 } from 'lucide-react';
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
import { EntryRow } from './EntryRow';
type SearchResultsProps = {
fileBrowserManager: UseFileBrowserWidgetType;
};
export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => {
const { searching, searchResults, openFile, isPinned, togglePin } = fileBrowserManager;
if (searching) {
return (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
if (searchResults.length === 0) {
return <p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>;
}
return (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
/>
))}
</ul>
);
};
@@ -0,0 +1 @@
export * from './FileBrowserWidget';
@@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { useRecentFiles } from '../useRecentFiles';
import { usePinnedFiles } from '../usePinnedFiles';
export type Tab = 'browse' | 'recent' | 'pinned';
export const useFileBrowserWidget = () => {
const navigate = useNavigate();
const { listDir, search } = useFilesAPI();
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
const [tab, setTab] = useState<Tab>('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
setLoading(true);
listDir(browsePath)
.then((res) => setEntries(res.entries))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [browsePath]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const q = searchQuery.trim();
if (!q) {
setSearchResults([]);
return;
}
debounceRef.current = setTimeout(() => {
setSearching(true);
search(q)
.then((res) => setSearchResults(res.results))
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery]);
const openFile = (path: string, name: string) => {
addRecent(path, name);
navigate(`/files?view=${encodeURIComponent(path)}`);
};
const isSearching = searchQuery.trim().length > 0;
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return {
tab,
setTab,
browsePath,
setBrowsePath,
entries: sorted,
loading,
searchQuery,
setSearchQuery,
searchResults,
searching,
isSearching,
openFile,
recents,
pinned,
togglePin,
isPinned,
};
};
export type UseFileBrowserWidgetType = ReturnType<typeof useFileBrowserWidget>;
@@ -0,0 +1,7 @@
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
export { useTasks, type TaskSummary } from './useTasks';
export { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles';
export { FileBrowserApp } from './FileBrowserApp';
export { FileBrowserWidget } from './FileBrowserWidget';
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
@@ -0,0 +1,19 @@
import { useUserState } from '@/state/useUserState';
type PinnedFile = { path: string; name: string; pinnedAt: number };
export const usePinnedFiles = () => {
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
const togglePin = (path: string, name: string) => {
setPinned((prev) => {
const exists = prev.some((f) => f.path === path);
if (exists) return prev.filter((f) => f.path !== path);
return [{ path, name, pinnedAt: Date.now() }, ...prev];
});
};
const isPinned = (path: string) => pinned.some((f) => f.path === path);
return { pinned, togglePin, isPinned };
};
@@ -0,0 +1,18 @@
import { useUserState } from '@/state/useUserState';
type RecentFile = { path: string; name: string; openedAt: number };
const MAX_RECENTS = 20;
export const useRecentFiles = () => {
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
const addRecent = (path: string, name: string) => {
setRecents((prev) => {
const filtered = prev.filter((f) => f.path !== path);
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
});
};
return { recents, addRecent };
};
@@ -1,4 +1,3 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
@@ -22,17 +21,14 @@ export const useTasks = () => {
staleTime: 60_000, staleTime: 60_000,
}); });
const getMatchingTasks = useCallback( const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
if (entryType === 'directory') { if (entryType === 'directory') {
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory')); return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
} }
const ext = fileName.split('.').pop()?.toLowerCase(); const ext = fileName.split('.').pop()?.toLowerCase();
if (!ext) return []; if (!ext) return [];
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext))); return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
}, };
[tasks],
);
return { tasks, getMatchingTasks }; return { tasks, getMatchingTasks };
}; };
@@ -0,0 +1,64 @@
import { useRef } from 'react';
import { Loader2, FolderArchive } from 'lucide-react';
import { getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { PdfRenderer } from './renderers/PdfRenderer';
import { ImageRenderer } from './renderers/ImageRenderer';
import { VideoRenderer } from './renderers/VideoRenderer';
import { AudioRenderer } from './renderers/AudioRenderer';
import { CodeRenderer } from './renderers/CodeRenderer';
import { MarkdownRenderer } from './renderers/MarkdownRenderer';
import { TextRenderer } from './renderers/TextRenderer';
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
export const FileViewerBody = () => {
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
const scrollRef = useRef<HTMLDivElement>(null);
const videoSrc =
fileType === 'video' ? (needsTranscode(fileName) ? getTranscodeUrl(filePath, root) : getRawUrl(filePath, root)) : '';
return (
<div ref={scrollRef} className="h-full overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">{error}</span>
</div>
) : fileType === 'pdf' ? (
<PdfRenderer src={getRawUrl(filePath, root)} />
) : fileType === 'archive' ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
<FolderArchive className="h-10 w-10" />
<span className="text-sm">Archive file</span>
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
) : fileType === 'video' ? (
<VideoRenderer src={videoSrc} fileName={fileName} />
) : fileType === 'audio' ? (
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
) : content !== null ? (
fileType === 'code' ? (
<div>
<CodeRenderer content={content} lang={getLang(fileName)} />
<ScrollToTopButton scrollContainer={scrollRef} />
</div>
) : (
<div className="px-8 py-6">
{fileType === 'markdown' ? (
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
) : (
<TextRenderer content={content} />
)}
<ScrollToTopButton scrollContainer={scrollRef} />
</div>
)
) : null}
</div>
);
};
@@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';
import type { FileType } from './file-types';
export type FileViewerContextValue = {
filePath: string;
fileName: string;
root: string;
fileType: FileType;
content: string | null;
loading: boolean;
error: string | null;
directContent: boolean;
ttsLoading: boolean;
ocrLoading: boolean;
transcribeLoading: boolean;
extractAudioLoading: boolean;
extractLoading: boolean;
autoPlay: boolean;
handleReadAloud: () => void;
handleOcr: () => void;
handleTranscribe: () => void;
handleExtractAudio: () => void;
handleExtract: () => void;
handleDownload: () => void;
};
export const FileViewerContext = createContext<FileViewerContextValue | null>(null);
export const useFileViewer = () => {
const ctx = useContext(FileViewerContext);
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
return ctx;
};

Some files were not shown because too many files have changed in this diff Show More