Workspaces layout, automation page

This commit is contained in:
2026-02-18 17:04:32 +00:00
parent 89f23f9426
commit 485ae4c3d7
89 changed files with 2168 additions and 514 deletions
@@ -1,108 +0,0 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import type { ChatMessage } from './types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
<div className="flex justify-center">
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm">
<summary className="text-xs text-duck-dark/40 hover:text-duck-dark/60 cursor-pointer select-none">
{label}
</summary>
<div className="mt-1.5 text-xs text-duck-dark/40 whitespace-pre-wrap border-l-2 border-duck-dark/10 pl-2 max-h-48 overflow-y-auto">
{content}
</div>
</details>
</div>
);
type MessageBubbleProps = {
message: ChatMessage;
onAnswer?: (text: string) => void;
};
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
switch (message.role) {
case 'user': {
const match = message.text.match(FRONTMATTER_RE);
const metadata = match ? match[1]!.trim() : null;
const text = match ? message.text.slice(match[0]!.length) : message.text;
return (
<>
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-duck-dark">
{message.images?.map((img, i) => (
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
))}
<div className="whitespace-pre-wrap">{text}</div>
</div>
</div>
</>
);
}
case 'system':
return <CollapsibleBlock label="System prompt" content={message.text} />;
case 'assistant':
if (!message.text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{message.text}
</ReactMarkdown>
</div>
</div>
);
case 'tool':
if (message.toolName === 'question' && onAnswer) {
return <QuestionActivity message={message} onAnswer={onAnswer} />;
}
return <ToolActivity message={message} />;
case 'result':
return (
<div className="flex justify-center py-1">
<span className="text-xs text-duck-dark/40">
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
{message.numTurns !== 1 ? 's' : ''}
{message.isError ? ' (with errors)' : ''}
</span>
</div>
);
case 'error':
return (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-700">
{message.text}
</div>
</div>
);
}
};
type StreamingBubbleProps = {
text: string;
};
export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{text}
</ReactMarkdown>
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
</div>
</div>
);
};
@@ -1,52 +0,0 @@
import type { RefObject } from 'react';
import { ArrowDown } from 'lucide-react';
import type { ChatMessage } from './types';
import { MessageBubble, StreamingBubble } from './MessageBubble';
type MessageListProps = {
messages: ChatMessage[];
streamingText: string;
isGenerating: boolean;
showJumpToBottom: boolean;
onJumpToBottom: () => void;
onQuestionAnswer?: (text: string) => void;
scrollViewportRef: RefObject<HTMLDivElement | null>;
bottomRef: RefObject<HTMLDivElement | null>;
};
export const MessageList = ({
messages,
streamingText,
isGenerating,
showJumpToBottom,
onJumpToBottom,
onQuestionAnswer,
scrollViewportRef,
bottomRef,
}: MessageListProps) => (
<div className="flex-1 min-h-0 relative">
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
<div className="p-4 space-y-3">
{messages.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
Send a message to start
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} onAnswer={onQuestionAnswer} />
))}
{isGenerating && <StreamingBubble text={streamingText} />}
<div ref={bottomRef} />
</div>
</div>
{showJumpToBottom && (
<button
onClick={onJumpToBottom}
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" />
</button>
)}
</div>
);
@@ -1,164 +0,0 @@
import { useState } from 'react';
import { MessageCircleQuestion, Check } from 'lucide-react';
import type { ChatMessage } from './types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type QuestionOption = {
label: string;
description: string;
};
type Question = {
question: string;
header: string;
multiple: boolean;
options: QuestionOption[];
};
type QuestionActivityProps = {
message: ToolMessage;
onAnswer: (text: string) => void;
};
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
const [otherText, setOtherText] = useState('');
const [answered, setAnswered] = useState(false);
const [answeredText, setAnsweredText] = useState('');
const input = message.toolInput as { questions?: Question[] };
const questions = input.questions;
if (!questions || questions.length === 0) return null;
const pending = message.output === undefined;
const handleSelect = (question: Question, label: string) => {
if (answered || !pending) return;
if (question.multiple) {
setSelectedOptions((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
} else {
const text = label;
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
}
};
const handleSubmitMultiple = () => {
if (selectedOptions.size === 0 || answered || !pending) return;
const text = Array.from(selectedOptions).join(', ');
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
};
const handleSubmitOther = () => {
const text = otherText.trim();
if (!text || answered || !pending) return;
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
};
const isDisabled = answered || !pending;
return (
<div className="my-1 space-y-3">
{questions.map((q, qi) => (
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
</div>
<div className="px-4 py-3 space-y-3">
<p className="text-sm text-duck-dark font-medium">{q.question}</p>
<div className="space-y-1.5">
{q.options.map((opt) => {
const isSelected = answered
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
: selectedOptions.has(opt.label);
return (
<button
key={opt.label}
onClick={() => handleSelect(q, opt.label)}
disabled={isDisabled}
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-duck-teal bg-duck-teal/10 text-duck-dark'
: isDisabled
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed'
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer'
}`}
>
<div className="flex items-center gap-2">
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
<div>
<span className="font-medium">{opt.label}</span>
{opt.description && <span className="text-duck-dark/50 ml-1.5">&mdash; {opt.description}</span>}
</div>
</div>
</button>
);
})}
</div>
{/* "Other" free-text option */}
{!isDisabled && (
<div className="flex gap-2">
<input
type="text"
value={otherText}
onChange={(ev) => setOtherText(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmitOther();
}
}}
placeholder="Other..."
className="flex-1 px-3 py-1.5 rounded-lg border border-duck-dark/15 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/40"
/>
<button
onClick={handleSubmitOther}
disabled={!otherText.trim()}
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
>
Send
</button>
</div>
)}
{/* Submit button for multi-select */}
{q.multiple && !isDisabled && (
<button
onClick={handleSubmitMultiple}
disabled={selectedOptions.size === 0}
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
>
Submit ({selectedOptions.size} selected)
</button>
)}
{/* Answered indicator */}
{isDisabled && answeredText && (
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
<Check className="h-3 w-3" />
<span>Answered: {answeredText}</span>
</div>
)}
</div>
</div>
))}
</div>
);
};
@@ -1,138 +0,0 @@
import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import type { ChatMessage } from './types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type ToolActivityProps = {
message: ToolMessage;
};
const toolIcons: Record<string, typeof FileText> = {
Read: FileText,
Edit: Pencil,
Write: Pencil,
Bash: Terminal,
Grep: Search,
Glob: Search,
WebFetch: Globe,
WebSearch: Globe,
};
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
switch (toolName) {
case 'Read':
case 'Edit':
case 'Write':
return (toolInput.file_path as string) ?? '';
case 'Bash':
return truncate((toolInput.command as string) ?? '', 80);
case 'Grep':
case 'Glob':
return (toolInput.pattern as string) ?? '';
case 'WebFetch':
return (toolInput.url as string) ?? '';
case 'WebSearch':
return (toolInput.query as string) ?? '';
default:
return (
Object.values(toolInput)
.find((v) => typeof v === 'string')
?.toString() ?? ''
);
}
}
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + '...' : str;
}
export const ToolActivity = ({ message }: ToolActivityProps) => {
const [open, setOpen] = useState(false);
const Icon = toolIcons[message.toolName] ?? Wrench;
const summary = getToolSummary(message.toolName, message.toolInput);
const pending = message.output === undefined;
const isError = message.isError === true;
return (
<div className="my-1">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
<span className="shrink-0">
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
</span>
</button>
{open && (
<div className="ml-7 mt-1 space-y-2 text-xs">
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
{message.toolName === 'Bash' ? (
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
</pre>
) : (
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
{Object.entries(message.toolInput)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join('\n')}
</pre>
)}
</div>
{message.output !== undefined && (
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
</div>
)}
</div>
)}
</div>
);
};
type ToolOutputProps = {
toolName: string;
output: string;
isError: boolean;
};
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
const [expanded, setExpanded] = useState(false);
const maxLines = 20;
const lines = output.split('\n');
const needsTruncation = lines.length > maxLines;
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
const isBash = toolName === 'Bash';
return (
<>
<pre
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 text-red-700' : 'text-duck-dark/70'
}`}
>
{displayText}
</pre>
{needsTruncation && (
<button
onClick={() => setExpanded(!expanded)}
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
>
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
</button>
)}
</>
);
};
-5
View File
@@ -1,5 +0,0 @@
export { MessageList } from './MessageList';
export { MessageBubble, StreamingBubble } from './MessageBubble';
export { ToolActivity } from './ToolActivity';
export { QuestionActivity } from './QuestionActivity';
export type { ChatMessage, SessionEntry, ServerMessage, TaskInfo } from './types';
-40
View File
@@ -1,40 +0,0 @@
export type SessionEntry = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'opencode';
model?: string | null;
};
export type ChatMessage =
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
| { role: 'assistant'; text: string }
| { role: 'system'; text: string }
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolUseId: string;
output?: string;
isError?: boolean;
}
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { role: 'error'; text: string };
export type TaskInfo = {
taskName: string;
taskDirName: string;
entryName: string;
entryType: 'file' | 'directory';
};
export type ServerMessage =
| { type: 'session:init'; sessionId: string; model: string }
| { type: 'system:prompt'; text: string }
| { type: 'assistant:text'; text: string }
| { type: 'assistant:partial'; text: string }
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { type: 'error'; message: string }
| { type: 'stopped' };
@@ -1,64 +0,0 @@
import { Link } from 'react-router';
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
type SessionBarProps = {
listPath: string;
provider: 'claude' | 'opencode';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
fullscreen: boolean;
onArchive: (() => void) | undefined;
onDelete: () => void;
onToggleFullscreen: () => void;
};
export const SessionBar = ({
listPath,
provider,
sessionTitle,
isConnected,
isGenerating,
fullscreen,
onArchive,
onDelete,
onToggleFullscreen,
}: SessionBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
<div className="flex items-center gap-1">
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
<button
onClick={onToggleFullscreen}
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
);
@@ -1 +0,0 @@
export { SessionBar } from './SessionBar';
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
import { Widget } from '../Widget';
export const Clock = () => {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const time = now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const date = now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return (
<Widget title="Clock">
<div className="flex flex-col items-center gap-1 px-4 pb-4">
<span className="text-3xl font-bold tabular-nums tracking-tight">{time}</span>
<span className="text-sm text-muted-foreground">{date}</span>
</div>
</Widget>
);
};
@@ -1,134 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import Editor, { type OnMount } from '@monaco-editor/react';
import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFiles } from 'widgets/FileBrowser';
import { FileTree } from './FileTree';
import { EditorTabs } from './EditorTabs';
import { useEditorState } from './useEditorState';
import { getLanguage } from './language-map';
type CodeEditorViewProps = {
className?: string;
theme?: string;
root?: string;
initialPath?: string;
};
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
useEditorState();
const { readFile, writeFile } = useFiles(root);
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
const activeFile = getActiveFile();
const handleOpenFile = useCallback(
async (path: string, name: string) => {
const existing = files.find((f) => f.path === path);
if (existing) {
setActivePath(path);
return;
}
try {
const res = await readFile(path);
openFile(path, name, res.content);
} catch {
toast.error('Failed to read file');
}
},
[files, setActivePath, readFile, openFile],
);
const handleSave = useCallback(async () => {
if (!activeFile || !activeFile.isDirty) return;
try {
await writeFile(activeFile.path, activeFile.content);
markSaved(activeFile.path, activeFile.content);
toast.success('File saved');
} catch {
toast.error('Failed to save file');
}
}, [activeFile, writeFile, markSaved]);
const handleEditorMount: OnMount = useCallback(
(editor, monaco) => {
editorRef.current = editor;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave();
});
},
[handleSave],
);
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const bg = theme === 'vs-dark' ? '#1e1e1e' : theme === 'vs' ? '#ffffff' : '#1e1e1e';
const borderColor = theme === 'vs-dark' ? '#333' : '#e0e0e0';
return (
<div className={className} style={{ background: bg }}>
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg" style={{ borderColor }}>
<ResizablePanel defaultSize={20} minSize={10} maxSize={40}>
<div className="h-full flex flex-col" style={{ background: bg }}>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider"
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
>
Explorer
</div>
<FileTree root={root} basePath={initialPath ?? '/'} onOpenFile={handleOpenFile} />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={80}>
<div className="h-full flex flex-col" style={{ background: bg }}>
<EditorTabs
files={files}
activePath={activePath}
onSelect={setActivePath}
onClose={closeFile}
theme={theme}
/>
{activeFile ? (
<Editor
key={activeFile.path}
theme={theme}
language={getLanguage(activeFile.name)}
value={activeFile.content}
onChange={(value) => setContent(activeFile.path, value ?? '')}
onMount={handleEditorMount}
options={{
automaticLayout: true,
minimap: { enabled: true },
fontSize: 14,
tabSize: 2,
wordWrap: 'on',
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
smoothScrolling: true,
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on',
padding: { top: 8 },
}}
/>
) : (
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
Open a file from the explorer to start editing
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
};
@@ -1,63 +0,0 @@
import { useMemo } from 'react';
import { X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState';
type EditorTabsProps = {
files: OpenFile[];
activePath: string | null;
onSelect: (path: string) => void;
onClose: (path: string) => void;
theme?: string;
};
const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: EditorTabsProps) => {
if (files.length === 0) return null;
const isDark = theme !== 'vs';
const borderColor = isDark ? '#333' : '#e0e0e0';
const activeBg = isDark ? '#1e1e1e' : '#ffffff';
const inactiveBg = isDark ? '#181818' : '#f3f3f3';
const activeColor = isDark ? '#ccc' : '#333';
const inactiveColor = isDark ? '#888' : '#666';
return (
<div className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable" style={{ borderBottom: `1px solid ${borderColor}` }}>
{files.map((file) => {
const isActive = file.path === activePath;
return (
<button
key={file.path}
onClick={() => onSelect(file.path)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors"
style={{
background: isActive ? activeBg : inactiveBg,
color: isActive ? activeColor : inactiveColor,
borderRight: `1px solid ${borderColor}`,
}}
>
<FileIcon name={file.name} />
<span>{file.name}</span>
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
<span
role="button"
className="ml-1 p-0.5 rounded transition-colors"
style={{ color: inactiveColor }}
onClick={(ev) => {
ev.stopPropagation();
onClose(file.path);
}}
>
<X className="h-3 w-3" />
</span>
</button>
);
})}
</div>
);
};
@@ -1,142 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
type FileTreeProps = {
root: string;
basePath: string;
onOpenFile: (path: string, name: string) => void;
};
type TreeNodeProps = {
entry: DirEntry;
parentPath: string;
root: string;
onOpenFile: (path: string, name: string) => void;
depth: number;
};
const FileIcon = ({ name }: { name: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
};
const sortEntries = (entries: DirEntry[]) => {
const dirs = entries.filter((e) => e.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
const files = entries.filter((e) => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...files];
};
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const { listDir } = useFiles(root);
const isDir = entry.type === 'directory';
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
const handleClick = useCallback(async () => {
if (!isDir) {
onOpenFile(fullPath, entry.name);
return;
}
if (!expanded && children === null) {
setLoading(true);
try {
const res = await listDir(fullPath);
setChildren(sortEntries(res.entries));
} catch {
setChildren([]);
}
setLoading(false);
}
setExpanded((prev) => !prev);
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
return (
<div>
<button
onClick={handleClick}
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{isDir ? (
<>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
)}
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
</>
) : (
<>
<span className="w-3.5 shrink-0" />
<FileIcon name={entry.name} />
</>
)}
<span className="truncate">{entry.name}</span>
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
</button>
{isDir && expanded && children && (
<div>
{children.map((child) => (
<TreeNode
key={child.name}
entry={child}
parentPath={fullPath}
root={root}
onOpenFile={onOpenFile}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
const { listDir } = useFiles(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const loadRoot = useCallback(async () => {
setLoading(true);
try {
const res = await listDir(basePath);
setEntries(sortEntries(res.entries));
} catch {
setEntries([]);
}
setLoading(false);
}, [listDir, basePath]);
if (entries === null && !loading) {
loadRoot();
}
if (loading && entries === null) {
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
}
if (!entries || entries.length === 0) {
return <div className="p-2 text-sm text-[#888]">No files</div>;
}
return (
<div className="py-1 overflow-y-auto h-full code-editor-scrollable">
{entries.map((entry) => (
<TreeNode
key={entry.name}
entry={entry}
parentPath={basePath}
root={root}
onOpenFile={onOpenFile}
depth={0}
/>
))}
</div>
);
};
@@ -1 +0,0 @@
export { CodeEditorView } from './CodeEditor';
@@ -1,235 +0,0 @@
const extensionToLanguage: Record<string, string> = {
// JavaScript / TypeScript
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
// Web
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
less: 'less',
vue: 'html',
svelte: 'html',
// Data / Config
json: 'json',
jsonc: 'json',
json5: 'json',
geojson: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'ini',
ini: 'ini',
cfg: 'ini',
conf: 'ini',
properties: 'ini',
env: 'ini',
xml: 'xml',
xsl: 'xml',
xslt: 'xml',
xsd: 'xml',
svg: 'xml',
plist: 'xml',
csproj: 'xml',
fsproj: 'xml',
vcxproj: 'xml',
sln: 'xml',
// Markdown / Text
md: 'markdown',
mdx: 'markdown',
markdown: 'markdown',
txt: 'plaintext',
log: 'plaintext',
// Shell
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
ksh: 'shell',
csh: 'shell',
ps1: 'powershell',
psm1: 'powershell',
psd1: 'powershell',
bat: 'bat',
cmd: 'bat',
// Python
py: 'python',
pyw: 'python',
pyi: 'python',
pyx: 'python',
ipynb: 'json',
// Ruby
rb: 'ruby',
erb: 'ruby',
gemspec: 'ruby',
rake: 'ruby',
// Rust
rs: 'rust',
// Go
go: 'go',
mod: 'go',
// Java / JVM
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
scala: 'scala',
sc: 'scala',
groovy: 'groovy',
gradle: 'groovy',
// C / C++ / Objective-C
c: 'c',
h: 'c',
cpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
hpp: 'cpp',
hxx: 'cpp',
hh: 'cpp',
m: 'objective-c',
mm: 'objective-c',
// C# / F#
cs: 'csharp',
csx: 'csharp',
fs: 'fsharp',
fsx: 'fsharp',
fsi: 'fsharp',
// Swift
swift: 'swift',
// Dart
dart: 'dart',
// PHP
php: 'php',
phtml: 'php',
// SQL
sql: 'sql',
mysql: 'sql',
pgsql: 'pgsql',
// Lua
lua: 'lua',
// R
r: 'r',
rmd: 'markdown',
// Perl
pl: 'perl',
pm: 'perl',
perl: 'perl',
// GraphQL
graphql: 'graphql',
gql: 'graphql',
// Docker
dockerfile: 'dockerfile',
// Elixir / Erlang
ex: 'elixir',
exs: 'elixir',
erl: 'erlang',
hrl: 'erlang',
// Haskell
hs: 'haskell',
lhs: 'haskell',
// Clojure
clj: 'clojure',
cljs: 'clojure',
cljc: 'clojure',
edn: 'clojure',
// Handlebars
hbs: 'handlebars',
handlebars: 'handlebars',
// Twig
twig: 'twig',
// Pug
pug: 'pug',
jade: 'pug',
// Coffee
coffee: 'coffeescript',
// Diff / Patch
diff: 'diff',
patch: 'diff',
// Protocol Buffers
proto: 'protobuf',
// Terraform
tf: 'hcl',
tfvars: 'hcl',
hcl: 'hcl',
// ABAP
abap: 'abap',
// Apex
apex: 'apex',
cls: 'apex',
trigger: 'apex',
// Pascal
pas: 'pascal',
pp: 'pascal',
// Tcl
tcl: 'tcl',
// Scheme / Lisp
scm: 'scheme',
ss: 'scheme',
rkt: 'scheme',
lisp: 'scheme',
lsp: 'scheme',
el: 'scheme',
// Misc
sol: 'sol',
bicep: 'bicep',
azcli: 'azcli',
redis: 'redis',
sb: 'sb',
st: 'st',
lex: 'lexon',
};
export const getLanguage = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const base = filename.toLowerCase();
if (base === 'dockerfile' || base.startsWith('dockerfile.')) return 'dockerfile';
if (base === 'makefile' || base === 'gnumakefile') return 'shell';
if (base === 'gemfile' || base === 'rakefile' || base === 'vagrantfile') return 'ruby';
if (base === 'justfile') return 'shell';
if (base === '.gitignore' || base === '.dockerignore' || base === '.editorconfig') return 'ini';
if (base === '.prettierrc' || base === '.eslintrc' || base === 'tsconfig.json' || base === 'package.json')
return 'json';
// Dotfiles that are shell scripts
if (/^\.(bash|zsh|sh|ksh|csh)rc$/.test(base)) return 'shell';
if (/^\.(bash_|zsh_|sh_)/.test(base)) return 'shell'; // .bash_profile, .zsh_history, etc.
if (base === '.profile' || base === '.bash_logout' || base === '.bash_login') return 'shell';
return extensionToLanguage[ext] ?? 'plaintext';
};
@@ -1,56 +0,0 @@
import { useState, useCallback } from 'react';
export type OpenFile = {
path: string;
name: string;
content: string;
originalContent: string;
isDirty: boolean;
};
export const useEditorState = () => {
const [files, setFiles] = useState<OpenFile[]>([]);
const [activePath, setActivePath] = useState<string | null>(null);
const openFile = useCallback((path: string, name: string, content: string) => {
setFiles((prev) => {
const existing = prev.find((f) => f.path === path);
if (existing) return prev;
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
});
setActivePath(path);
}, []);
const closeFile = useCallback(
(path: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.path !== path);
if (activePath === path) {
const idx = prev.findIndex((f) => f.path === path);
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
setActivePath(newActive?.path ?? null);
}
return next;
});
},
[activePath],
);
const setContent = useCallback((path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
);
}, []);
const markSaved = useCallback((path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
);
}, []);
const getActiveFile = useCallback((): OpenFile | null => {
return files.find((f) => f.path === activePath) ?? null;
}, [files, activePath]);
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
};
@@ -1,43 +0,0 @@
import { ChevronRight, Home } from 'lucide-react';
type BreadcrumbProps = {
path: string;
onNavigate: (path: string) => void;
};
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => {
const segments = path.split('/').filter(Boolean);
return (
<nav className="flex items-center gap-1 text-sm flex-wrap">
<button
onClick={() => onNavigate('/')}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" />
<span>home</span>
</button>
{segments.map((segment, i) => {
const segmentPath = '/' + segments.slice(0, i + 1).join('/');
const isLast = i === segments.length - 1;
return (
<span key={segmentPath} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
{isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span>
) : (
<button
onClick={() => onNavigate(segmentPath)}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment}
</button>
)}
</span>
);
})}
</nav>
);
};
@@ -1,202 +0,0 @@
import { useRef, useState, useCallback } from 'react';
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
type ToolbarProps = {
onCreateDir: (name: string) => void;
onUpload: (files: FileList) => void;
selectionCount: number;
hasClipboard: boolean;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDeleteSelected: () => void;
onClearSelection: () => void;
};
export const Toolbar = ({
onCreateDir,
onUpload,
selectionCount,
hasClipboard,
onCut,
onCopy,
onPaste,
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 = '';
}
}}
/>
</>
);
if (selectionCount > 0) {
return (
<div className="flex items-center gap-1">
<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={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">
{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-white/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>
);
};
@@ -1,4 +0,0 @@
export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { useFiles, type DirEntry } from './useFiles';
export { useTasks, type TaskSummary } from './useTasks';
@@ -1,90 +0,0 @@
import { useClient, getHeaders } from 'hooks/useClient';
import { config } from 'config';
export type DirEntry = {
name: string;
path?: string;
type: 'file' | 'directory';
size: number;
modifiedAt: number;
};
type ListDirResponse = {
path: string;
entries: DirEntry[];
reset?: boolean;
};
export const useFiles = (root: string = 'home') => {
const client = useClient();
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
const withRoot = (url: string) =>
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
return {
listDir: (path: string) =>
client.get<ListDirResponse>(withRoot(`/file-browser/ls?path=${encodeURIComponent(path)}`)),
createDir: (path: string) => client.post(withRoot('/file-browser/mkdir'), { path }),
remove: (path: string) => client.delete(withRoot('/file-browser/rm'), { path }),
rename: (path: string, newName: string) => client.post(withRoot('/file-browser/rename'), { path, newName }),
readFile: (path: string) =>
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
writeFile: (path: string, content: string) =>
client.post(withRoot('/file-browser/write'), { path, content }),
search: (query: string) =>
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
copy: (sources: string[], destination: string) =>
client.post(withRoot('/file-browser/copy'), {
items: sources.map((source) => ({
source,
destination: `${destination}/${source.split('/').pop()}`,
})),
}),
move: (sources: string[], destination: string) =>
client.post(withRoot('/file-browser/move'), {
items: sources.map((source) => ({
source,
destination: `${destination}/${source.split('/').pop()}`,
})),
}),
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
const formData = new FormData();
for (const file of Array.from(files)) {
const name = (file as any).webkitRelativePath || file.name;
formData.append('file', file, name);
}
const authHeaders = getHeaders();
const url = withRoot(`${config.API_URL}/file-browser/upload?path=${encodeURIComponent(path)}`);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
if (authHeaders['Authorization']) {
xhr.setRequestHeader('Authorization', authHeaders['Authorization']);
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 400) reject(new Error(xhr.responseText));
else resolve();
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(formData);
});
},
};
};
@@ -1,38 +0,0 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
export type TaskSummary = {
dirName: string;
name: string;
description: string;
scope: 'user' | 'global';
triggers: TriggerConfig[];
filePath: string;
};
export const useTasks = () => {
const client = useClient();
const { data: tasks = [] } = useQuery<TaskSummary[]>({
queryKey: ['tasks'],
queryFn: () => client.get('/tasks'),
staleTime: 60_000,
});
const getMatchingTasks = useCallback(
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
if (entryType === 'directory') {
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
}
const ext = fileName.split('.').pop()?.toLowerCase();
if (!ext) return [];
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
},
[tasks],
);
return { tasks, getMatchingTasks };
};
@@ -1,188 +0,0 @@
import type { CSSProperties } from 'react';
import { useEffect, useRef } from 'react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { useMounted } from 'hooks/useMounted';
type TerminalTheme = {
background?: string;
foreground?: string;
cursor?: string;
selectionBackground?: string;
};
export type TerminalViewProps = {
className?: string;
style?: CSSProperties;
wsPath?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
autoFocus?: boolean;
onReady?: (term: XTerm) => void;
onExit?: () => void;
onDisconnect?: () => void;
};
const DEFAULT_THEME: Required<TerminalTheme> = {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
selectionBackground: '#3a3a5e',
};
const buildWsUrl = (wsPath: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
};
export const TerminalView = ({
className,
style,
wsPath = '/api/terminal/ws',
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
autoFocus = true,
onReady,
onExit,
onDisconnect,
}: TerminalViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
onReadyRef.current = onReady;
onExitRef.current = onExit;
onDisconnectRef.current = onDisconnect;
const background = theme?.background ?? DEFAULT_THEME.background;
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
const cursor = theme?.cursor ?? DEFAULT_THEME.cursor;
const selectionBackground = theme?.selectionBackground ?? DEFAULT_THEME.selectionBackground;
useEffect(() => {
if (!isMounted) return;
const container = containerRef.current;
if (!container) return;
let disposed = false;
const initTimeout = setTimeout(() => {
if (disposed) return;
const term = new XTerm({
cursorBlink: true,
fontSize,
fontFamily,
theme: {
background,
foreground,
cursor,
selectionBackground,
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
if (autoFocus) term.focus();
termRef.current = term;
fitAddonRef.current = fitAddon;
onReadyRef.current?.(term);
const ws = new WebSocket(buildWsUrl(wsPath));
wsRef.current = ws;
const handleOpen = () => {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
const handleMessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data as string);
if (msg.type === 'output') {
term.write(msg.data);
} else if (msg.type === 'exit') {
term.write('\r\n[Process exited]\r\n');
onExitRef.current?.();
}
} catch {
// ignore
}
};
const handleClose = () => {
term.write('\r\n[Disconnected]\r\n');
onDisconnectRef.current?.();
};
ws.addEventListener('open', handleOpen);
ws.addEventListener('message', handleMessage);
ws.addEventListener('close', handleClose);
const dataDisposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
});
resizeObserver.observe(container);
const cleanup = () => {
dataDisposable.dispose();
resizeObserver.disconnect();
ws.removeEventListener('open', handleOpen);
ws.removeEventListener('message', handleMessage);
ws.removeEventListener('close', handleClose);
};
(container as any).__terminalCleanup = cleanup;
}, 0);
return () => {
disposed = true;
clearTimeout(initTimeout);
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
cleanup?.();
delete (container as any).__terminalCleanup;
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitAddonRef.current = null;
};
}, [
isMounted,
wsPath,
fontSize,
fontFamily,
background,
foreground,
cursor,
selectionBackground,
autoFocus,
]);
return (
<div
ref={containerRef}
className={className}
style={{ backgroundColor: background, ...style }}
/>
);
};
-1
View File
@@ -1 +0,0 @@
export { TerminalView, type TerminalViewProps } from './Terminal';
+177
View File
@@ -0,0 +1,177 @@
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
import { cn } from 'helpers/cn';
import { Card } from '@/components/Card';
type Position = { x: number; y: number };
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
title?: string;
resizable?: boolean;
collapsible?: boolean | { title: string; icon?: LucideIcon };
moveable?: boolean;
position?: Position;
onPositionChange?: (pos: Position) => void;
onClose?: () => void;
};
export const Widget = ({ title, className, style, resizable, collapsible, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
const [expanded, setExpanded] = useState(true);
const [minimized, setMinimized] = useState(false);
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
const isControlled = controlledPosition !== undefined;
const position = isControlled ? controlledPosition : internalPosition;
const setPosition = isControlled ? (pos: Position | ((prev: Position) => Position)) => {
const next = typeof pos === 'function' ? pos(controlledPosition) : pos;
onPositionChange?.(next);
} : setInternalPosition;
const cardRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
startX: number;
startY: number;
originX: number;
originY: number;
naturalLeft: number;
naturalTop: number;
cardWidth: number;
cardHeight: number;
} | null>(null);
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
const HEADER_HEIGHT = 64;
const toggleMinimized = useCallback(() => {
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect();
preToggleRect.current = { left: rect.left, top: rect.top };
}
setMinimized((v) => !v);
}, []);
useLayoutEffect(() => {
if (!cardRef.current || !preToggleRect.current) return;
const prev = preToggleRect.current;
preToggleRect.current = null;
const newRect = cardRef.current.getBoundingClientRect();
setPosition((p) => ({
x: p.x + (prev.left - newRect.left),
y: p.y + (prev.top - newRect.top),
}));
}, [minimized]);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
if (!moveable) return;
const target = ev.target as HTMLElement;
const card = ev.currentTarget as HTMLElement;
const isCardPadding = target === card;
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
if (!isCardPadding && !isHeader) return;
if (isHeader && ev.detail === 2) {
toggleMinimized();
return;
}
const rect = card.getBoundingClientRect();
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: position.x,
originY: position.y,
naturalLeft: rect.left - position.x,
naturalTop: rect.top - position.y,
cardWidth: rect.width,
cardHeight: rect.height,
};
card.setPointerCapture(ev.pointerId);
},
[moveable, position],
);
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dx = ev.clientX - d.startX;
const dy = ev.clientY - d.startY;
const newX = d.originX + dx;
const newY = d.originY + dy;
const vw = window.innerWidth;
const vh = window.innerHeight;
setPosition({
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
});
}, []);
const onPointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
const moveableStyle: CSSProperties | undefined = moveable
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
: undefined;
return (
<Card
ref={cardRef}
className={cn(
'relative p-0',
collapsible && 'pt-0',
moveable && 'cursor-grab [&>*]:cursor-auto',
className,
minimized && '!h-auto !w-auto',
)}
style={{ ...resizableStyle, ...moveableStyle, ...style }}
onPointerDown={moveable ? onPointerDown : undefined}
onPointerMove={moveable ? onPointerMove : undefined}
onPointerUp={moveable ? onPointerUp : undefined}
{...props}
>
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
<div className="ml-auto flex items-center gap-0.5">
<button
type="button"
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => toggleMinimized()}
>
{minimized ? <Plus size={14} /> : <Minus size={14} />}
</button>
{onClose && (
<button
type="button"
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onClose}
>
<X size={14} />
</button>
)}
</div>
</div>
{!minimized && (
<>
{collapsible && (
<button
type="button"
className="flex w-full items-center gap-2 p-3 cursor-pointer"
onClick={() => setExpanded((v) => !v)}
>
{typeof collapsible === 'object' && collapsible.icon && (
<collapsible.icon size={16} className="text-muted-foreground" />
)}
{typeof collapsible === 'object' && collapsible.title && (
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
)}
<span className="ml-auto text-muted-foreground">
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</span>
</button>
)}
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
</>
)}
</Card>
);
};
@@ -0,0 +1,25 @@
import { widgetRegistry } from '../widget-registry';
type WidgetPickerProps = {
onSelect: (widgetType: string) => void;
};
export const WidgetPicker = ({ onSelect }: WidgetPickerProps) => {
const entries = Object.entries(widgetRegistry);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
{entries.map(([key, entry]) => (
<button
key={key}
type="button"
className="flex flex-col items-center gap-1.5 rounded-lg border border-border/50 bg-card/80 backdrop-blur-sm px-3 py-3 text-foreground hover:border-border hover:bg-card transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-5 w-5" />
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
</button>
))}
</div>
);
};
@@ -0,0 +1,238 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { Plus, X } from 'lucide-react';
import { widgetRegistry } from '../widget-registry';
import { WidgetPicker } from './WidgetPicker';
type Position = { x: number; y: number };
type WidgetInstance = {
id: string;
widgetType: string;
position: Position;
};
type WidgetPanelConfig = {
instances: WidgetInstance[];
nextId: number;
};
const USER_STATE_KEY = ['USER_STATE'];
function useWidgetPanelState(panelId: string) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const stateKey = `widget-panel:${panelId}`;
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const config = (state[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const setConfig = useCallback(
(update: WidgetPanelConfig | ((prev: WidgetPanelConfig) => WidgetPanelConfig)) => {
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
const current = (currentState[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const next = typeof update === 'function' ? update(current) : update;
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [stateKey]: next });
clientRef.current.patch('/user/state', { [stateKey]: next }).catch(() => {});
},
[stateKey, queryClient],
);
return [config, setConfig] as const;
}
// --- DraggableWidget ---
type DraggableWidgetProps = {
instance: WidgetInstance;
panelRef: React.RefObject<HTMLDivElement | null>;
onMove: (pos: Position) => void;
onRemove: () => void;
};
type DragState = {
startX: number;
startY: number;
originX: number;
originY: number;
widgetW: number;
widgetH: number;
panelW: number;
panelH: number;
};
const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidgetProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
const target = ev.target as HTMLElement;
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
const widgetRect = containerRef.current?.getBoundingClientRect();
const panelRect = panelRef.current?.getBoundingClientRect();
if (!widgetRect || !panelRect) return;
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: instance.position.x,
originY: instance.position.y,
widgetW: widgetRect.width,
widgetH: widgetRect.height,
panelW: panelRect.width,
panelH: panelRect.height,
};
containerRef.current?.setPointerCapture(ev.pointerId);
},
[instance.position, panelRef],
);
const endDrag = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
},
[],
);
const onPointerMove = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const panelRect = panelRef.current?.getBoundingClientRect();
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
endDrag(ev);
return;
}
const d = dragRef.current;
const rawX = d.originX + (ev.clientX - d.startX);
const rawY = d.originY + (ev.clientY - d.startY);
onMove({
x: Math.max(0, Math.min(rawX, d.panelW - d.widgetW)),
y: Math.max(0, Math.min(rawY, d.panelH - d.widgetH)),
});
},
[onMove, panelRef, endDrag],
);
const onPointerUp = useCallback(
(ev: ReactPointerEvent) => {
endDrag(ev);
},
[endDrag],
);
const entry = widgetRegistry[instance.widgetType];
if (!entry) return null;
const WidgetComponent = entry.component;
return (
<div
ref={containerRef}
className="absolute group"
style={{ left: instance.position.x, top: instance.position.y }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<button
type="button"
className="absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 shadow transition-opacity cursor-pointer group-hover:opacity-100"
onClick={onRemove}
>
<X size={12} />
</button>
<WidgetComponent panelId={instance.id} />
</div>
);
};
// --- WidgetPanel ---
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const [config, setConfig] = useWidgetPanelState(panelId);
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
const [showPicker, setShowPicker] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const nextId = useRef(config.nextId);
const initialized = useRef(false);
// Sync from persisted state on first load
useEffect(() => {
if (initialized.current) return;
if (config.instances.length > 0 || config.nextId > 0) {
setInstances(config.instances);
nextId.current = config.nextId;
initialized.current = true;
}
}, [config]);
// Persist whenever instances change (skip the initial mount)
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
setConfig({ instances, nextId: nextId.current });
}, [instances, setConfig]);
const addWidget = useCallback((widgetType: string) => {
nextId.current++;
const id = `widget-${nextId.current}`;
const offset = (nextId.current % 5) * 30;
setInstances((prev) => [...prev, { id, widgetType, position: { x: 20 + offset, y: 20 + offset } }]);
setShowPicker(false);
}, []);
const removeWidget = useCallback((id: string) => {
setInstances((prev) => prev.filter((w) => w.id !== id));
}, []);
const updatePosition = useCallback((id: string, pos: Position) => {
setInstances((prev) => prev.map((w) => (w.id === id ? { ...w, position: pos } : w)));
}, []);
return (
<div ref={panelRef} className="relative h-full w-full overflow-hidden">
{instances.map((instance) => (
<DraggableWidget
key={instance.id}
instance={instance}
panelRef={panelRef}
onMove={(pos) => updatePosition(instance.id, pos)}
onRemove={() => removeWidget(instance.id)}
/>
))}
<div className="absolute bottom-4 right-4 flex flex-col items-end">
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />
</div>
)}
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors cursor-pointer"
onClick={() => setShowPicker((v) => !v)}
>
<Plus size={20} />
</button>
</div>
</div>
);
};
@@ -0,0 +1,54 @@
import { Link } from 'react-router';
import { LayoutGrid, ArrowRight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { WorkspaceDefinition } from '@/components/Workspace';
import { Widget } from '../Widget';
export const Workspaces = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: ['USER_STATE'],
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const workspaces = (state.workspaces ?? []) as WorkspaceDefinition[];
return (
<Widget title="Workspaces">
<div className="px-2 pb-3 max-h-52 overflow-y-auto">
{workspaces.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-4 text-muted-foreground">
<LayoutGrid className="h-5 w-5" />
<p className="text-xs">No workspaces</p>
<Link to="/workspaces" className="text-xs text-primary hover:underline">
Create one
</Link>
</div>
) : (
<ul className="space-y-0.5">
{workspaces.map((ws) => (
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group">
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
<LayoutGrid className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{ws.name}</span>
</Link>
<Link
to={`/workspaces/${ws.id}`}
className="shrink-0 p-1 rounded text-muted-foreground/40 md:opacity-0 md:group-hover:opacity-100 hover:text-primary transition-opacity"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</li>
))}
</ul>
)}
</div>
</Widget>
);
};
+5 -5
View File
@@ -2,10 +2,10 @@
"name": "widgets",
"private": true,
"exports": {
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts",
"./CodeEditor": "./CodeEditor/index.ts"
"./Widget": "./Widget.tsx",
"./Clock": "./Clock/index.tsx",
"./widget-registry": "./widget-registry.tsx",
"./WidgetPanel": "./WidgetPanel/index.tsx",
"./Workspaces": "./Workspaces/index.tsx"
}
}
@@ -0,0 +1,9 @@
import { Clock as ClockIcon, LayoutGrid } from 'lucide-react';
import type { AppRegistryEntry } from '@/components/Workspace';
import { Clock } from './Clock/index';
import { Workspaces } from './Workspaces/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
'workspaces': { name: 'Workspaces', icon: LayoutGrid, component: () => <Workspaces /> },
};