Workspaces in Workspaces all around

This commit is contained in:
2026-02-19 01:49:15 +00:00
parent 72bca6cd42
commit dd8ab84df5
84 changed files with 3047 additions and 749 deletions
+5 -5
View File
@@ -41,11 +41,11 @@ export function App() {
<Route path="/settings/server" element={<Dashboard.ServerSettings />} />
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
<Route path="/automation" element={<Dashboard.Automation />} />
<Route path="/chat" element={<Dashboard.SessionList />} />
<Route path="/chat/new" element={<Dashboard.NewChat />} />
<Route path="/chat/:sessionId" element={<Dashboard.ClaudeChat />} />
<Route path="/chat/opencode/new" element={<Dashboard.OpenCodeChat />} />
<Route path="/chat/opencode/:sessionId" element={<Dashboard.OpenCodeChat />} />
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/chat/opencode/new" element={<Dashboard.SessionListPage provider="opencode" isNew />} />
<Route path="/chat/opencode/:sessionId" element={<Dashboard.SessionListPage provider="opencode" />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesPage />} />
<Route path="/terminal" element={<Dashboard.Terminal />} />
@@ -53,7 +53,7 @@ export function ForgotPassword() {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="you@example.com"
@@ -39,7 +39,7 @@ export function ResetPassword() {
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
@@ -50,7 +50,7 @@ export function ResetPassword() {
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
@@ -48,7 +48,7 @@ export function Login() {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="you@example.com"
@@ -59,7 +59,7 @@ export function Login() {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Password</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Your password"
@@ -1,7 +1,9 @@
import { Background } from './Background';
type AuthenticationLayoutProps = {
children?: React.ReactNode;
};
export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
return (
<div className="relative overflow-hidden h-dvh outline-none inset-0">
@@ -13,9 +15,6 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
</div>
</div>
</section>
</div>
);
};
}
@@ -38,7 +38,7 @@ export const VerifyScreen = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
disabled
@@ -48,7 +48,7 @@ export const VerifyScreen = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
@@ -59,7 +59,7 @@ export const VerifyScreen = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="username"
placeholder="your-username"
@@ -71,7 +71,7 @@ export const VerifyScreen = () => {
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
@@ -82,7 +82,7 @@ export const VerifyScreen = () => {
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { toast } from 'sonner';
import { Trash2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { CapabilityChat } from '../CapabilityPage';
@@ -10,6 +12,7 @@ export const AutomationEditChat = () => {
const client = useClient();
const qc = useQueryClient();
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
const [chatKey, setChatKey] = useState(0);
const { data: detail } = useQuery<CapabilityDetail>({
queryKey: [selection?.queryKey, selection?.dirName],
@@ -22,30 +25,48 @@ export const AutomationEditChat = () => {
setSelection({ ...selection, editing: false });
};
const deleteChat = async () => {
if (!selection) return;
try {
await client.delete(`${selection.endpoint}/${selection.dirName}/chat`);
await qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] });
setChatKey((k) => k + 1);
} catch {
toast.error('Failed to delete chat');
}
};
if (!selection || !detail?.filePath) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/40">Select a resource and click edit to chat</p>
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">Select a resource and click edit to chat</p>
</div>
);
}
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name ?? 'Chat'}</span>
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">{detail.name ?? 'Chat'}</span>
<button
onClick={deleteChat}
className="p-1 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Delete chat history"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
</button>
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<CapabilityChat
kind={selection.kind.toLowerCase()}
key={detail.filePath}
key={`${detail.filePath}-${chatKey}`}
endpoint={selection.endpoint}
dirName={selection.dirName}
filePath={detail.filePath}
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
chatSessionId={detail.chatSessionId}
chatSessionId={chatKey > 0 ? null : detail.chatSessionId}
isNew={selection.isNew}
description={selection.description}
onResponseEnd={() => qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })}
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { toast } from 'sonner';
import { ArrowLeft, Pencil, Trash2 } from 'lucide-react';
import { ArrowLeft, Pencil, Play, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
@@ -13,6 +13,53 @@ import { Card } from '@/components/Card';
import { FrontmatterBlock } from '../CapabilityPage';
import type { CapabilityDetail } from '../CapabilityPage';
import type { AutomationSelection } from './AutomationRightPanel';
import { TaskRunnerModal } from '../Files/Screen/TaskRunnerModal';
type SelectOption = { value: string; label: string };
type TaskInput = {
name: string;
description: string;
type: 'string' | 'number' | 'boolean' | 'select';
required: boolean;
default: string;
options: SelectOption[];
};
function parseInputs(rawYaml: string): TaskInput[] {
// Match the inputs block: everything indented after "inputs:" until next top-level key or end
const inputsMatch = rawYaml.match(/^inputs:\s*\n((?:[ \t]+.*\n?)*)/m);
if (!inputsMatch) return [];
const block = inputsMatch[1]!;
// Split on list items that have a "name:" field (e.g. " - name: country")
const items = block.split(/(?=[ \t]+-\s*name\s*:)/);
return items
.filter((item) => /name\s*:/.test(item))
.map((item) => {
const name = item.match(/name\s*:\s*(.+)/)?.[1]?.trim() ?? '';
const description = item.match(/description\s*:\s*(.+)/)?.[1]?.trim() ?? '';
const rawType = item.match(/type\s*:\s*(.+)/)?.[1]?.trim() ?? 'string';
const type = (['string', 'number', 'boolean', 'select'] as const).includes(rawType as 'string')
? (rawType as TaskInput['type'])
: 'string';
const required = item.match(/required\s*:\s*(.+)/)?.[1]?.trim() === 'true';
const defaultFromField = item.match(/default\s*:\s*(.+)/)?.[1]?.trim();
const defaultFromDesc = description.match(/[Dd]efaults?\s+to\s+(\S+?)\.?\s*$/)?.[1];
const options: SelectOption[] = [];
const optionsMatch = item.match(/options\s*:\s*\n((?:[ \t]+.*\n?)*)/);
if (optionsMatch) {
const optionEntries = optionsMatch[1]!.split(/(?=[ \t]*- \s*value\s*:)/);
for (const entry of optionEntries) {
const value = entry.match(/value\s*:\s*(.+)/)?.[1]?.trim() ?? '';
const label = entry.match(/label\s*:\s*(.+)/)?.[1]?.trim() ?? value;
if (value) options.push({ value, label });
}
}
return { name, description, type, required, default: defaultFromField ?? defaultFromDesc ?? '', options };
});
}
type CapabilityDetailViewProps = {
kind: string;
@@ -28,12 +75,45 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
const qc = useQueryClient();
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [showInputForm, setShowInputForm] = useState(false);
const [runPrompt, setRunPrompt] = useState<string | null>(null);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const { data: detail } = useQuery<CapabilityDetail>({
queryKey: [queryKey, dirName],
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
});
const taskInputs = useMemo(() => (detail?.rawFrontmatter ? parseInputs(detail.rawFrontmatter) : []), [detail?.rawFrontmatter]);
const handleRunClick = () => {
if (taskInputs.length > 0) {
const defaults: Record<string, string> = {};
for (const input of taskInputs) {
if (input.type === 'boolean') {
defaults[input.name] = input.default === 'true' ? 'true' : 'false';
} else if (input.type === 'select') {
defaults[input.name] = input.default || (input.options[0]?.value ?? '');
} else {
defaults[input.name] = input.default;
}
}
setInputValues(defaults);
setShowInputForm(true);
} else {
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them`);
}
};
const handleInputSubmit = () => {
const parts = Object.entries(inputValues)
.filter(([, v]) => v !== '')
.map(([k, v]) => `- ${k}: ${v}`);
const suffix = parts.length > 0 ? `\n\nInputs:\n${parts.join('\n')}` : '';
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them${suffix}`);
setShowInputForm(false);
};
const toggleEditing = () => {
if (!selection) return;
setSelection({ ...selection, editing: !editing });
@@ -53,24 +133,33 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
return (
<>
<Card className="h-full overflow-hidden flex flex-col min-h-0">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
<ArrowLeft className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
</button>
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail?.name ?? `Loading...`}</span>
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{detail?.name ?? `Loading...`}</span>
{detail && (
<>
{kind === 'Task' && (
<button
onClick={handleRunClick}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
title="Run task"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
)}
<button
onClick={toggleEditing}
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
className={`p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
>
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50 dark:text-foreground/50'}`} />
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
</button>
</>
)}
@@ -84,7 +173,7 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
</ReactMarkdown>
</article>
) : detail ? (
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">Empty file</p>
) : null}
</div>
</Card>
@@ -107,6 +196,89 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
</div>
</DialogContent>
</Dialog>
<Dialog open={showInputForm} onOpenChange={setShowInputForm}>
<DialogContent className="sm:max-w-lg z-[700]">
<DialogHeader>
<DialogTitle>Run {detail?.name}</DialogTitle>
<DialogDescription>Configure inputs before running.</DialogDescription>
</DialogHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
handleInputSubmit();
}}
className="flex flex-col gap-3 mt-1"
>
{taskInputs.map((input) => (
<div key={input.name} className="flex flex-col gap-1">
{input.type === 'boolean' ? (
<label className="flex items-center gap-2 text-sm font-medium text-foreground/70 cursor-pointer">
<input
type="checkbox"
checked={inputValues[input.name] === 'true'}
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.checked ? 'true' : 'false' }))}
className="h-4 w-4 rounded border-foreground/20 accent-duck-teal"
/>
{input.name}
{input.required && <span className="text-destructive ml-0.5">*</span>}
</label>
) : (
<>
<label className="text-sm font-medium text-foreground/70">
{input.name}
{input.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{input.type === 'select' ? (
<select
value={inputValues[input.name] ?? ''}
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
required={input.required}
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
>
{input.options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : (
<input
type={input.type === 'number' ? 'number' : 'text'}
value={inputValues[input.name] ?? ''}
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
placeholder={input.default || undefined}
required={input.required}
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
)}
</>
)}
{input.description && (
<span className="text-xs text-foreground/40">{input.description}</span>
)}
</div>
))}
<div className="flex justify-end gap-2 mt-2">
<Button type="button" variant="outline" onClick={() => setShowInputForm(false)} className="cursor-pointer">
Cancel
</Button>
<Button type="submit" className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer">
Run
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{runPrompt !== null && detail && (
<TaskRunnerModal
open
onOpenChange={(open) => {
if (!open) setRunPrompt(null);
}}
task={{ dirName, name: detail.name, description: detail.description ?? '', scope: detail.scope ?? 'user', triggers: [], filePath: detail.filePath }}
promptOverride={runPrompt}
/>
)}
</>
);
};
@@ -35,23 +35,23 @@ export const CapabilityList = ({ kind, endpoint, queryKey }: CapabilityListProps
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">{kind}s</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{kind}s</span>
<button
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '', creating: true })}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<Plus className="h-3.5 w-3.5 text-duck-dark/50" />
<Plus className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="px-4 pt-3">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30 dark:text-foreground/30" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search..."
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="w-full rounded border border-duck-dark/15 dark:border-foreground/15 bg-background/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
</div>
@@ -63,18 +63,18 @@ export const CapabilityList = ({ kind, endpoint, queryKey }: CapabilityListProps
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: c.dirName })}
className={`w-full text-left px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
selection?.queryKey === queryKey && selection.dirName === c.dirName
? 'bg-duck-teal/10 text-duck-dark'
: 'text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark'
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/60 dark:text-foreground/60 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`}
>
<span className="font-medium">{c.name}</span>
{c.description && (
<p className="text-xs text-duck-dark/40 mt-0.5 line-clamp-1">{c.description}</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 mt-0.5 line-clamp-1">{c.description}</p>
)}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
{items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'}
</p>
)}
@@ -43,18 +43,18 @@ export const NewCron = ({ selection }: NewCronProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Cron</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Cron</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewCron = ({ selection }: NewCronProps) => {
}}
placeholder="Cron name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this cron job schedules..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewPipeline = ({ selection }: NewPipelineProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Pipeline</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Pipeline</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewPipeline = ({ selection }: NewPipelineProps) => {
}}
placeholder="Pipeline name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this pipeline orchestrates..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewProcess = ({ selection }: NewProcessProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Process</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Process</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewProcess = ({ selection }: NewProcessProps) => {
}}
placeholder="Process name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this process manages..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewService = ({ selection }: NewServiceProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Service</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Service</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewService = ({ selection }: NewServiceProps) => {
}}
placeholder="Service name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this service provides..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewSkill = ({ selection }: NewSkillProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Skill</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Skill</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewSkill = ({ selection }: NewSkillProps) => {
}}
placeholder="Skill name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this skill does..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewTask = ({ selection }: NewTaskProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Task</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Task</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewTask = ({ selection }: NewTaskProps) => {
}}
placeholder="Task name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this task automates..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -43,18 +43,18 @@ export const NewWorkflow = ({ selection }: NewWorkflowProps) => {
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Workflow</span>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Workflow</span>
<button
onClick={() => setSelection(null)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Name</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={name}
onChange={(ev) => setName(ev.target.value)}
@@ -66,17 +66,17 @@ export const NewWorkflow = ({ selection }: NewWorkflowProps) => {
}}
placeholder="Workflow name..."
autoFocus
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70">Description</label>
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="Describe what this workflow automates..."
rows={4}
className="rounded border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end">
@@ -8,10 +8,12 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useVisibleClaudeModels } from '@/state/useModels';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings';
import { Card } from '@/components/Card';
import type { ChatMessage } from 'apps/Chat';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
type CapabilitySummary = {
dirName: string;
@@ -57,7 +59,11 @@ type CapabilityChatProps = {
onResponseEnd?: () => void;
};
export const CapabilityChat = ({
type CapabilityChatInnerProps = CapabilityChatProps & {
onProviderChange: (p: 'claude' | 'opencode') => void;
};
const CapabilityChatClaude = ({
kind,
endpoint,
dirName,
@@ -67,7 +73,8 @@ export const CapabilityChat = ({
isNew,
description,
onResponseEnd,
}: CapabilityChatProps) => {
onProviderChange,
}: CapabilityChatInnerProps) => {
const client = useClient();
const claudeModels = useVisibleClaudeModels();
const seedFile = `${kind.toUpperCase()}.md`;
@@ -117,6 +124,7 @@ export const CapabilityChat = ({
chat={claude}
provider="claude"
availableModels={claudeModels}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
@@ -124,6 +132,59 @@ export const CapabilityChat = ({
);
};
const CapabilityChatOpenCode = ({
kind,
filePath,
resourceDir,
isNew,
description,
onResponseEnd,
onProviderChange,
}: CapabilityChatInnerProps) => {
const openCodeModels = useVisibleOpenCodeModels();
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 defaultInput = isNew
? description ?? `Help me create the content for this new ${kind} file`
: `Help me understand and improve this ${kind} file`;
const opencode = useOpenCode(undefined, undefined, { replaceUrl: false });
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !opencode.isGenerating) {
onResponseEndRef.current?.();
}
wasGenerating.current = opencode.isGenerating;
}, [opencode.isGenerating]);
return (
<EmbeddableChat
chat={opencode}
provider="opencode"
availableModels={openCodeModels}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
/>
);
};
export const CapabilityChat = (props: CapabilityChatProps) => {
const { settings } = useSettings();
const [provider, setProvider] = useState<'claude' | 'opencode'>(settings.chat.defaultProvider);
return provider === 'claude' ? (
<CapabilityChatClaude key="claude" {...props} onProviderChange={setProvider} />
) : (
<CapabilityChatOpenCode key="opencode" {...props} onProviderChange={setProvider} />
);
};
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const [open, setOpen] = useState(false);
@@ -182,7 +243,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
return (
<>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center justify-between">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
{!creating && (
<button
@@ -213,7 +274,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
}
}}
placeholder={`${kind} name...`}
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-white px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
<button
onClick={handleCreate}
@@ -241,7 +302,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
value={internalSearch}
onChange={(ev) => setInternalSearch(ev.target.value)}
placeholder={`Search ${kind.toLowerCase()}s...`}
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
</div>
@@ -353,7 +414,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
{/* Right panel — detail + chat */}
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${showDetail ? 'flex' : 'hidden md:flex'}`}>
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setShowDetail(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
@@ -400,7 +461,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
{editing && detail?.filePath && selected && (
<Card className="flex-1 overflow-hidden flex flex-col min-h-0">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setEditing(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
@@ -186,7 +186,7 @@ export const InputArea = ({
};
return (
<div className="shrink-0 border-t border-duck-dark/10 bg-white/60 p-2 md:p-3">
<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>
)}
@@ -296,7 +296,7 @@ export const InputArea = ({
}}
placeholder="Type a message..."
rows={1}
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-white/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"
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">
@@ -343,7 +343,7 @@ export const InputArea = ({
}
}}
placeholder="https://example.com"
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 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"
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 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"
autoFocus
/>
<Button
@@ -38,13 +38,13 @@ export const Settings = ({
{provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
) : (
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
{(['claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => onProviderChange?.(value)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
provider === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
provider === value ? 'bg-background text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{value === 'claude' ? 'Claude' : 'OpenCode'}
@@ -1,79 +1 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import type { SessionEntry } from 'apps/Chat';
import { useClaude } from './useClaude';
import { useOpenCode } from './useOpenCode';
import { ChatPanel } from './ChatPanel';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
export const ClaudeChat = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const queryClient = useQueryClient();
const sessions = queryClient.getQueryData<SessionEntry[]>(['SESSIONS']);
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
const claude = useClaude(sessionId, sessionModel);
const claudeModels = useVisibleClaudeModels();
return (
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} />
</div>
</div>
);
};
export const OpenCodeChat = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const queryClient = useQueryClient();
const sessions = queryClient.getQueryData<SessionEntry[]>(['OC_SESSIONS']);
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
const opencode = useOpenCode(sessionId, sessionModel);
const openCodeModels = useVisibleOpenCodeModels();
return (
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
<ChatPanel chat={opencode} provider="opencode" availableModels={openCodeModels} />
</div>
</div>
);
};
const ClaudeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const claude = useClaude();
const claudeModels = useVisibleClaudeModels();
return (
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} onProviderChange={onProviderChange} />
);
};
const OpenCodeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const opencode = useOpenCode();
const openCodeModels = useVisibleOpenCodeModels();
return (
<ChatPanel
chat={opencode}
provider="opencode"
availableModels={openCodeModels}
onProviderChange={onProviderChange}
/>
);
};
export const NewChat = () => {
const [provider, setProvider] = useState<'claude' | 'opencode'>('claude');
return (
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
{provider === 'claude' ? (
<ClaudeNewChatInner key="claude" onProviderChange={setProvider} />
) : (
<OpenCodeNewChatInner key="opencode" onProviderChange={setProvider} />
)}
</div>
</div>
);
};
export {};
@@ -0,0 +1,291 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { Trash2, Archive } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
export type SelectedSession = {
id: string;
provider: 'claude' | 'opencode';
model?: string | null;
} | null;
const CHANNEL = 'chat:selected-session';
type ChatLocationState = {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
type DetailBarProps = {
provider: 'claude' | 'opencode';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
onArchive: (() => void) | undefined;
onDelete: (() => void) | undefined;
};
const DetailBar = ({
provider,
sessionTitle,
isConnected,
isGenerating,
onArchive,
onDelete,
}: DetailBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/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 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/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>
</div>
</div>
);
type InnerProps = {
sessionId: string;
model?: string | null;
};
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
const chat = useClaude(sessionId, model, { replaceUrl: false });
const models = useVisibleClaudeModels();
const { sessions, archiveSession, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={async () => {
await archiveSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
onDelete={async () => {
await deleteSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="claude" availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
const chat = useOpenCode(sessionId, model, { replaceUrl: false });
const models = useVisibleOpenCodeModels();
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={async () => {
await deleteSession('opencode', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="opencode" availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useClaude();
const models = useVisibleClaudeModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
provider="claude"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
cwd={locationState?.cwd}
className="flex-1 min-h-0"
/>
</div>
);
};
const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useOpenCode();
const models = useVisibleOpenCodeModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'opencode', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
provider="opencode"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
</div>
);
};
type NewChatPanelProps = {
initialProvider?: 'claude' | 'opencode';
};
const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => {
const [selected, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const provider = selected?.provider ?? initialProvider;
const handleProviderChange = (p: 'claude' | 'opencode') => {
setSelected({ id: 'new', provider: p });
};
// Once a session is created, the inner component updates selected via the channel
if (selected && selected.id !== 'new') {
return selected.provider === 'claude' ? (
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
) : (
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
);
}
return provider === 'claude' ? (
<NewClaudeInner key="new-claude" onProviderChange={handleProviderChange} />
) : (
<NewOpenCodeInner key="new-opencode" onProviderChange={handleProviderChange} />
);
};
export const ChatDetailPanel = () => {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) {
return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
Select a session to view
</div>
);
}
if (selected.id === 'new') {
return <NewChatPanel key="new" initialProvider={selected.provider} />;
}
return selected.provider === 'claude' ? (
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
) : (
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
);
};
@@ -1,66 +1,111 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import type { SelectedSession } from './ChatDetailPanel';
type Filter = 'all' | 'claude' | 'opencode';
export const SessionList = () => {
const [filter, setFilter] = useState<Filter>('all');
const { sessions, deleteSession } = useChatSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const scrolledRef = useRef(false);
const selectedRef = useCallback(
(node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
}
},
[],
);
useEffect(() => {
scrolledRef.current = false;
}, [selected?.id, selected?.provider]);
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
const path = session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`;
window.history.replaceState(null, '', path);
};
const handleDelete = async (provider: 'claude' | 'opencode', id: string) => {
if (selected?.id === id && selected?.provider === provider) {
setSelected(null);
window.history.replaceState(null, '', '/chat');
}
await deleteSession(provider, id);
};
return (
<div className="flex flex-col h-full items-center p-4 md:p-6">
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
<Link to="/chat/new">
<Plus className="h-4 w-4" />
New Chat
</Link>
</Button>
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex items-center gap-2">
{/* Radio filter */}
<div className="flex items-center gap-0.5 rounded-lg bg-duck-dark/5 dark:bg-foreground/5 p-0.5">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer ${
filter === value
? 'bg-background dark:bg-foreground/10 text-duck-dark dark:text-foreground shadow-sm'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/80 dark:hover:text-foreground/80'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
<button
onClick={() => {
setSelected({ id: 'new', provider: 'claude' });
window.history.replaceState(null, '', '/chat/new');
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</div>
</div>
{/* Radio filter */}
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
filter === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
</div>
)}
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
)}
{filtered.map((session) => (
{filtered.map((session) => {
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
return (
<div
key={`${session.provider}-${session.id}`}
className="group flex items-center gap-3 rounded-lg border border-duck-dark/10 bg-white/80 hover:bg-white/90 transition-colors"
ref={isSelected ? selectedRef : undefined}
className={`group flex items-center gap-3 rounded-lg border transition-colors cursor-pointer ${
isSelected
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
<button
onClick={() => handleSelect(session)}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
<div className="text-xs text-duck-dark/40">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
@@ -74,20 +119,22 @@ export const SessionList = () => {
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
<span className="ml-2 font-mono text-duck-dark/25 dark:text-foreground/25">
{session.id.slice(0, 8)}
</span>
</div>
</div>
</Link>
</button>
<button
onClick={() => deleteSession(session.provider, session.id)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => handleDelete(session.provider, session.id)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</Card>
);
})}
</div>
</div>
);
};
@@ -1,2 +1,57 @@
import { useMemo, useEffect } from 'react';
import { useParams } from 'react-router';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import { appRegistry } from '../Workspaces/app-registry';
import { SessionList } from './Screen';
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
export { ChatHistory as ChatHistoryApp } from './Widget';
export { SessionList } from './Screen';
export { SessionList };
const layout: LayoutNode = {
type: 'group',
id: 'chat-history-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'chat-list', appType: null }, size: 35 },
{ node: { type: 'panel', id: 'chat-detail', appType: null }, size: 65 },
],
};
type SessionListPageProps = {
provider?: 'claude' | 'opencode';
isNew?: boolean;
};
export const SessionListPage = ({ provider = 'claude', isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
useEffect(() => {
if (isNew) {
setSelected({ id: 'new', provider });
return;
}
if (!sessionId) return;
const session = sessions.find((s) => s.id === sessionId && s.provider === provider);
setSelected({ id: sessionId, provider, model: session?.model ?? null });
}, [sessionId, provider, isNew]);
const panelComponents: PanelComponents = useMemo(
() => ({
'chat-list': SessionList,
'chat-detail': ChatDetailPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
@@ -15,6 +15,7 @@ type FileGridProps = {
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;
@@ -63,6 +64,7 @@ export const FileGrid = ({
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
@@ -143,6 +145,7 @@ export const FileGrid = ({
onDelete={onDelete}
onRename={onRename}
onChat={onChat}
onDownload={onDownload}
onSelect={handleSelect}
onCut={onCut}
onCopy={onCopy}
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play } from 'lucide-react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -35,6 +35,7 @@ export type FileItemProps = {
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
onCut: () => void;
onCopy: () => void;
@@ -64,6 +65,7 @@ type MenuItemsProps = {
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
onDownload: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
@@ -76,6 +78,7 @@ const DropdownMenuItems = ({
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -86,6 +89,10 @@ const DropdownMenuItems = ({
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
@@ -130,6 +137,7 @@ const ContextMenuItems = ({
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -140,6 +148,10 @@ const ContextMenuItems = ({
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
@@ -237,7 +249,7 @@ const InlineRenameInput = ({
if (ev.key === 'Escape') onCancel();
}}
onClick={(ev) => ev.stopPropagation()}
className="text-sm font-medium text-duck-dark bg-white border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
/>
);
};
@@ -257,7 +269,7 @@ const Checkbox = ({
onClick(ev);
}}
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-white/80 hover:border-duck-teal/50'
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
{checked && <Check className="h-3 w-3" />}
@@ -280,6 +292,7 @@ export const FileItem = ({
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
@@ -358,6 +371,7 @@ export const FileItem = ({
onDelete,
onStartRename: () => setRenaming(true),
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -401,7 +401,7 @@ const MarkdownRenderer = ({
// ── Text renderer ──
const TextRenderer = ({ content }: { content: string }) => (
<pre className="whitespace-pre-wrap font-mono text-sm text-duck-dark leading-relaxed p-4">{content}</pre>
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
);
// ── Code renderer with syntax highlighting ──
@@ -851,7 +851,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
{/* Image area */}
<div
ref={containerRef}
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
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}
@@ -880,7 +880,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
</div>
{/* Toolbar */}
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-white/80">
<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}
@@ -1050,22 +1050,12 @@ export const FileViewer = ({ open, onOpenChange, filePath, fileName, root }: Fil
className={`fixed left-[50%] top-[50%] z-[600] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${
expanded ? 'w-[95vw] h-[95vh]' : 'w-[90vw] max-w-4xl h-[85vh]'
}`}
style={
fileType === 'video'
? { backgroundColor: '#000' }
: cardStyle({
backgroundColor: 'rgba(255, 255, 255, 0.97)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
`,
})
}
style={fileType === 'video' ? { backgroundColor: '#000' } : cardStyle()}
>
{/* Header */}
<div
className={`shrink-0 flex items-center gap-3 px-5 py-3 border-b ${
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-white/60'
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-background/60'
}`}
>
{headerIcon}
@@ -104,17 +104,21 @@ type TaskRunnerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
task: TaskSummary;
entryName: string;
entryType: 'file' | 'directory';
cwd: { root?: string; path: string };
entryName?: string;
entryType?: 'file' | 'directory';
cwd?: { root?: string; path: string };
promptOverride?: string;
};
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd }: TaskRunnerModalProps) => {
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
const { settings } = useSettings();
const taskSettings = settings.tasks;
const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider);
const defaultInput = `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`;
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName, entryType };
const defaultInput = promptOverride
?? (entryName && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
: `Read the task instructions at ${task.filePath} and execute them`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -123,16 +127,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
<DialogPrimitive.Content
onOpenAutoFocus={(ev) => ev.preventDefault()}
className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
style={cardStyle({
backgroundColor: 'rgba(255, 255, 255, 0.97)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
`,
})}
style={cardStyle()}
>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-white/60">
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-background/60">
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
@@ -5,8 +5,7 @@ import {
Loader2,
LayoutGrid,
List,
Maximize2,
Minimize2,
ClipboardPaste,
FolderPlus,
Search,
@@ -43,7 +42,7 @@ export const Files = () => {
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
const [fullscreen, setFullscreen] = useUserState<boolean>('files/fullscreen', false);
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -240,6 +239,25 @@ export const Files = () => {
});
};
const handleDownload = async (entry: DirEntry) => {
const path = entryPath(entry.name);
try {
await files.download([path]);
} catch {
toast.error('Failed to download');
}
};
const handleDownloadSelected = async () => {
const paths = selectedPaths();
if (paths.length === 0) return;
try {
await files.download(paths);
} catch {
toast.error('Failed to download');
}
};
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
setRunningTask({ task, entry });
};
@@ -378,6 +396,7 @@ export const Files = () => {
{/* Toolbar */}
<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">
<Toolbar
onRefresh={refresh}
onCreateDir={handleCreateDir}
onUpload={handleUpload}
selectionCount={selected.size}
@@ -385,6 +404,7 @@ export const Files = () => {
onCut={handleCut}
onCopy={handleCopy}
onPaste={handlePaste}
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
onClearSelection={() => setSelected(new Set())}
/>
@@ -402,7 +422,7 @@ export const Files = () => {
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-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
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') {
@@ -454,7 +474,7 @@ export const Files = () => {
}
}}
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-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
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
@@ -486,12 +506,6 @@ export const Files = () => {
<List className="h-4 w-4" />
</button>
</div>
<button
onClick={() => setFullscreen((f) => !f)}
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
{/* Home dir selector (Super Admin only) */}
@@ -603,6 +617,7 @@ export const Files = () => {
onDelete={handleDelete}
onRename={handleRename}
onChat={handleChat}
onDownload={handleDownload}
onSelect={setSelected}
onCut={handleCut}
onCopy={handleCopy}
@@ -96,7 +96,7 @@ export const FileBrowser = () => {
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-white 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"
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
@@ -1,14 +1,30 @@
import { Card } from '@/components/Card';
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { appRegistry } from '../Workspaces/app-registry';
import { Files as FilesInner } from './Screen';
export { FilesInner as Files };
export const FilesPage = () => (
<div className="flex items-center justify-center h-full p-0 md:p-4">
<Card className="flex flex-col overflow-hidden w-full h-full md:w-[90vw] md:h-[90%] lg:w-[50vw]">
<FilesInner />
</Card>
</div>
);
const layout: LayoutNode = {
type: 'panel',
id: 'files-main',
appType: null,
};
export const FilesPage = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'files-main': FilesInner,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
export { FileBrowser as FileBrowserApp } from './Widget';
@@ -266,7 +266,7 @@ export const ChatLauncher = () => {
}}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
provider === value
? 'bg-white text-duck-dark shadow-sm'
? 'bg-background text-duck-dark shadow-sm'
: 'text-duck-dark/50 hover:text-duck-dark/70'
}`}
>
@@ -317,7 +317,7 @@ export const ChatLauncher = () => {
}
}}
placeholder="https://example.com"
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 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"
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 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"
autoFocus
/>
<Button
@@ -19,7 +19,8 @@ const ICON_GAP = 24;
const DOCK_PADDING = 12;
const MAX_SCALE = 1.5;
const MAX_DISTANCE = 150;
const BOTTOM_THRESHOLD = 100;
const SHOW_THRESHOLD = 24;
const HIDE_THRESHOLD = 100;
const getScale = (mouseX: number | null, iconCenterX: number) => {
if (mouseX === null) return 1;
@@ -39,12 +40,14 @@ export const Dock = ({ items, className }: DockProps) => {
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
const distFromBottom = window.innerHeight - ev.clientY;
if (distFromBottom <= BOTTOM_THRESHOLD) {
setVisible(true);
setVisible((prev) => {
if (!prev) return distFromBottom <= SHOW_THRESHOLD;
return distFromBottom <= HIDE_THRESHOLD;
});
if (distFromBottom <= HIDE_THRESHOLD) {
const rect = dockRef.current?.getBoundingClientRect();
if (rect) setMouseX(ev.clientX - rect.left);
} else {
setVisible(false);
setMouseX(null);
}
};
@@ -17,9 +17,7 @@ export function Header() {
/>
</Link>
<UserMenu />
</div>
</header>
);
}
@@ -1,17 +1,27 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Server, Package, Bot } from 'lucide-react';
import { User, LogOut, Server, Package, Bot, Sun, Moon } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useSettings } from '@/state/useSettings';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
export function UserMenu() {
const { user, isLoading } = useAuth();
const { t } = useTranslation();
const { colorMode, setColorMode } = useColorMode();
const { settings, saveSettings } = useSettings();
if (isLoading) return null;
const toggleColorMode = () => {
const next = colorMode === 'dark' ? 'light' : 'dark';
setColorMode(next);
saveSettings({ ...settings, appearance: { ...settings.appearance, colorMode: next } });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -49,6 +59,16 @@ export function UserMenu() {
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
{colorMode === 'dark' ? (
<Sun className="mr-2 h-4 w-4" />
) : (
<Moon className="mr-2 h-4 w-4" />
)}
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />
@@ -30,13 +30,13 @@ export const Plans = () => {
<div className="flex flex-col h-full p-4">
<Card className="flex-1 overflow-hidden">
{/* Header with plan selector */}
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-white/60">
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
{plans.length > 1 && (
<select
value={selectedPlan ?? ''}
onChange={(ev) => setSelectedPlan(ev.target.value)}
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-white/80"
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
>
{plans.map((p) => (
<option key={p} value={p}>
@@ -49,7 +49,7 @@ export const Plans = () => {
{/* Markdown content */}
<div className="overflow-y-auto h-full p-6">
<div className="prose prose-sm max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal 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 prose-td:text-sm prose-th:text-sm">
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal 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 prose-td:text-sm prose-th:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Trash2 } from 'lucide-react';
import { Terminal, Eye, Trash2, Bot } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
@@ -10,7 +10,10 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card } from '@/components/Card';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from './SettingsPanel';
import { useSettings } from '@/state/useSettings';
import { useUserState } from '@/state/useUserState';
import {
@@ -21,38 +24,47 @@ import {
} from '@/state/useModels';
import type { UserSettings } from '@/state/types/user-settings';
const GLOBAL_KEY = 'AI_SETTINGS_SELECTED';
const sections: SettingsSection[] = [
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
{ key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
sidebarIcon: Bot,
sidebarLabel: 'AI',
sections,
});
const layout: LayoutNode = {
type: 'group',
id: 'ai-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'ai-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'ai-right', appType: null }, size: 80 },
],
};
export const AISettings = () => {
const [mainTab, setMainTab] = useUserState('ai-settings-tab', 'chat-defaults');
const panelComponents: PanelComponents = useMemo(
() => ({
'ai-left': Sidebar,
'ai-right': Content,
}),
[],
);
return (
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
<Card className="w-full max-w-2xl h-fit p-6">
<Tabs value={mainTab} onValueChange={setMainTab}>
<TabsList className="w-full mb-6">
<TabsTrigger value="chat-defaults" className="flex-1 gap-2 cursor-pointer">
<Terminal className="h-4 w-4" />
Chat Defaults
</TabsTrigger>
<TabsTrigger value="model-visibility" className="flex-1 gap-2 cursor-pointer">
<Eye className="h-4 w-4" />
Model Visibility
</TabsTrigger>
</TabsList>
<TabsContent value="chat-defaults">
<ChatDefaultsSection />
</TabsContent>
<TabsContent value="model-visibility">
<ModelVisibilitySection />
</TabsContent>
</Tabs>
</Card>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
const ChatDefaultsSection = () => {
function ChatDefaultsSection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useVisibleClaudeModels();
const openCodeModels = useVisibleOpenCodeModels();
@@ -102,7 +114,7 @@ const ChatDefaultsSection = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Model</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent className="z-[600]">
@@ -119,7 +131,7 @@ const ChatDefaultsSection = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">System Prompt</span>
<Textarea
className="bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
className="bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
placeholder="Custom instructions for the AI..."
value={systemPrompt}
onChange={(ev) => setSystemPrompt(ev.target.value)}
@@ -137,7 +149,7 @@ const ChatDefaultsSection = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Working Directory</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
value={defaultPwd}
onChange={(ev) => setDefaultPwd(ev.target.value)}
placeholder="~"
@@ -154,9 +166,9 @@ const ChatDefaultsSection = () => {
</Button>
</div>
);
};
}
const ModelVisibilitySection = () => {
function ModelVisibilitySection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useClaudeModels();
const openCodeModels = useOpenCodeModels();
@@ -234,7 +246,7 @@ const ModelVisibilitySection = () => {
{addingProvider ? (
<>
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
<SelectTrigger className="h-9 flex-1 bg-white/60 border-duck-dark/20 text-duck-dark text-sm">
<SelectTrigger className="h-9 flex-1 bg-background/60 border-duck-dark/20 text-duck-dark text-sm">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent className="z-[600]">
@@ -282,7 +294,7 @@ const ModelVisibilitySection = () => {
</TabsContent>
</Tabs>
);
};
}
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
@@ -52,7 +52,7 @@ export const ChangePassword = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.changePassword.currentPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
@@ -63,7 +63,7 @@ export const ChangePassword = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.changePassword.newPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="newPassword"
placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
@@ -74,7 +74,7 @@ export const ChangePassword = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.changePassword.confirmPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
@@ -91,7 +91,7 @@ export const Languages = () => {
<select
value={addingLang}
onChange={(ev) => addSpoken(ev.target.value)}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-white/60 text-duck-dark cursor-pointer"
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => (
@@ -110,7 +110,7 @@ export const Languages = () => {
<select
value={defaultLang}
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{spoken.map((code) => (
<option key={code} value={code}>
@@ -127,7 +127,7 @@ export const Languages = () => {
<select
value={translateTo}
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
@@ -58,7 +58,7 @@ export const TaskDefaults = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
@@ -95,7 +95,7 @@ export const UserData = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
value={user?.email ?? ''}
disabled
@@ -105,7 +105,7 @@ export const UserData = () => {
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
@@ -1,106 +1,52 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
import { useMemo } from 'react';
import { User, Lock, Globe, ListChecks } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { appRegistry } from '../../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { TaskDefaults } from './TaskDefaults';
export const ProfileSettings = () => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string[]>([]);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
const sections = useMemo(
() => [
{
key: 'profile',
icon: User,
title: t('settings.profile.sections.profileTitle'),
description: t('settings.profile.sections.profileDescription'),
content: <UserData />,
},
{
key: 'change-password',
icon: Lock,
title: t('settings.profile.sections.changePasswordTitle'),
description: t('settings.profile.sections.changePasswordDescription'),
content: <ChangePassword />,
},
{
key: 'tasks',
icon: ListChecks,
title: t('settings.profile.sections.tasksTitle'),
description: t('settings.profile.sections.tasksDescription'),
content: <TaskDefaults />,
},
{
key: 'languages',
icon: Globe,
title: t('settings.profile.sections.languagesTitle'),
description: t('settings.profile.sections.languagesDescription'),
content: <Languages />,
},
],
[t],
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
{ key: 'tasks', icon: ListChecks, title: 'Task Defaults', description: 'Default model for tasks', content: <TaskDefaults /> },
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
sidebarIcon: User,
sidebarLabel: 'Profile',
sections,
});
const layout: LayoutNode = {
type: 'group',
id: 'profile-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'profile-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'profile-right', appType: null }, size: 80 },
],
};
export const ProfileSettings = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'profile-left': Sidebar,
'profile-right': Content,
}),
[],
);
const allKeys = useMemo(() => sections.map((s) => s.key), [sections]);
const matchingKeys = useMemo(() => {
if (!search) return allKeys;
const query = search.toLowerCase();
return sections
.filter((s) => {
const el = sectionRefs.current[s.key];
return (el?.textContent?.toLowerCase() ?? '').includes(query);
})
.map((s) => s.key);
}, [search, allKeys, sections]);
useEffect(() => {
if (search) setExpanded(matchingKeys);
}, [search, matchingKeys]);
return (
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
<Card className="w-full max-w-2xl h-fit p-6">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
<Input
placeholder={t('settings.profile.searchPlaceholder')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="pl-9"
/>
</div>
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
{sections.map((section) => (
<div
key={section.key}
ref={(el) => {
sectionRefs.current[section.key] = el;
}}
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
>
<AccordionItem value={section.key}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
<div className="text-base font-bold text-duck-dark">{section.title}</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-1">{section.content}</AccordionContent>
</AccordionItem>
</div>
))}
</Accordion>
</Card>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
@@ -29,13 +29,13 @@ const CopyCommand = ({ command }: { command: string }) => {
return (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
<code className="flex-1 bg-duck-dark/5 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />}
</button>
</div>
);
@@ -77,9 +77,9 @@ export const Applications = () => {
return (
<div className="h-full overflow-y-auto p-6">
<h2 className="text-lg font-bold text-duck-dark mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
{apps && (
<div className="flex flex-col gap-3">
@@ -88,18 +88,18 @@ export const Applications = () => {
const canAutoRun = hasAutoAction(app);
return (
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
{app.installed && (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
{app.version}
</span>
)}
{!app.installed && (
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
Not installed
</span>
)}
@@ -109,7 +109,7 @@ export const Applications = () => {
/>
)}
</div>
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
</div>
<div className="shrink-0">
@@ -147,7 +147,7 @@ export const Applications = () => {
</div>
{manualCmd && (
<div className="mt-2 text-xs text-duck-dark/50">
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{app.installed ? 'Update' : 'Install'} manually:
<CopyCommand command={manualCmd} />
</div>
@@ -36,9 +36,8 @@ export const ResourceSidebar = () => {
<button
key={r.id}
onClick={() => setSelectedId(r.id)}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
}`}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
}`}
>
{r.port ? (
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
@@ -50,9 +49,8 @@ export const ResourceSidebar = () => {
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
</div>
<Circle
className={`h-2 w-2 shrink-0 mt-1.5 ${
r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
}`}
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
}`}
/>
</button>
);
@@ -1,96 +1,48 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Search, Terminal, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card';
import { useMemo } from 'react';
import { Terminal, Shield, Server } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { appRegistry } from '../../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { AIHarnessesSection } from './AIHarnessesSection';
// import { PluginsSection } from './PluginsSection';
import { TerminalSection } from './TerminalSection';
const sections = [
{
key: 'ai-harnesses',
icon: Terminal,
title: 'AI Harnesses',
description: 'Which AI coding tools do you use?',
content: <AIHarnessesSection />,
},
// {
// key: 'plugins',
// icon: Puzzle,
// title: 'Plugins',
// description: 'Enable or disable installed plugins.',
// content: <PluginsSection />,
// },
{
key: 'terminal',
icon: Shield,
title: 'Terminal',
description: 'Sandbox and access controls for the Terminal plugin.',
content: <TerminalSection />,
},
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
const sections: SettingsSection[] = [
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
{ key: 'terminal', icon: Shield, title: 'Terminal', description: 'Sandbox and access controls', content: <TerminalSection /> },
];
const allKeys = sections.map((s) => s.key);
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
sidebarIcon: Server,
sidebarLabel: 'Server',
sections,
});
const layout: LayoutNode = {
type: 'group',
id: 'server-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'server-right', appType: null }, size: 80 },
],
};
export const ServerSettings = () => {
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string[]>(allKeys);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const matchingKeys = useMemo(() => {
if (!search) return allKeys;
const query = search.toLowerCase();
return sections
.filter((s) => {
const el = sectionRefs.current[s.key];
return (el?.textContent?.toLowerCase() ?? '').includes(query);
})
.map((s) => s.key);
}, [search]);
useEffect(() => {
setExpanded(search ? matchingKeys : allKeys);
}, [search, matchingKeys]);
const panelComponents: PanelComponents = useMemo(
() => ({
'server-left': Sidebar,
'server-right': Content,
}),
[],
);
return (
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
<Card className="w-full max-w-2xl h-fit p-6">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
<Input
placeholder="Search settings..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="pl-9"
/>
</div>
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
{sections.map((section) => (
<div
key={section.key}
ref={(el) => {
sectionRefs.current[section.key] = el;
}}
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
>
<AccordionItem value={section.key}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
<div className="text-left">
<div className="text-base font-bold text-duck-dark">{section.title}</div>
<div className="text-sm font-normal text-duck-dark/70">{section.description}</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent forceMount>{section.content}</AccordionContent>
</AccordionItem>
</div>
))}
</Accordion>
</Card>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
@@ -0,0 +1,108 @@
import type { ComponentType, ReactNode } from 'react';
import { useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
export type SettingsSection = {
key: string;
icon: LucideIcon;
title: string;
description: string;
content: ReactNode;
};
type SettingsSidebarProps = {
globalKey: string;
icon: LucideIcon;
label: string;
sections: SettingsSection[];
};
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections }: SettingsSidebarProps) => {
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
const [search, setSearch] = useState('');
const query = search.toLowerCase();
const filtered = sections.filter(
(s) => s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query),
);
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Icon className="h-4 w-4" />
{label}
</div>
</div>
<div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
</div>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{filtered.map((s) => {
const isActive = selectedKey === s.key;
return (
<button
key={s.key}
onClick={() => setSelectedKey(s.key)}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`}
>
<s.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{s.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{s.description}</div>
</div>
</button>
);
})}
</div>
</div>
);
};
type SettingsContentProps = {
globalKey: string;
sections: SettingsSection[];
};
export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) => {
const [selectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
const section = sections.find((s) => s.key === selectedKey);
if (!section) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
</div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
<div className="max-w-xl">{section.content}</div>
</div>
);
};
type CreateSettingsPanelParams = {
globalKey: string;
sidebarIcon: LucideIcon;
sidebarLabel: string;
sections: SettingsSection[];
};
export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections }: CreateSettingsPanelParams) => {
const Sidebar: ComponentType = () => (
<SettingsSidebar globalKey={globalKey} icon={sidebarIcon} label={sidebarLabel} sections={sections} />
);
const Content: ComponentType = () => <SettingsContent globalKey={globalKey} sections={sections} />;
return { Sidebar, Content };
};
@@ -28,7 +28,7 @@ const formatDate = (iso: string) => {
const ProviderBadge = ({ provider }: { provider: string }) => (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`}
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' : 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'}`}
>
{provider}
</span>
@@ -89,7 +89,7 @@ export const TaskLogs = () => {
placeholder="Search logs..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-white/60 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
/>
</div>
</div>
@@ -161,7 +161,7 @@ export const TaskLogs = () => {
</div>
</div>
{selectedLog.isError && (
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">Error</span>
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">Error</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
@@ -1,10 +1,51 @@
import { Link } from 'react-router';
import { LayoutGrid, Plus } from 'lucide-react';
import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState';
import type { WorkspaceDefinition } from '@/components/Workspace';
import {
SELECTED_WORKSPACE_KEY,
CREATING_WORKSPACE_KEY,
EDITING_WORKSPACE_KEY,
NEW_WS_NAME_KEY,
NEW_WS_DESC_KEY,
NEW_WS_TEMPLATE_KEY,
} from './constants';
export const WorkspaceListApp = () => {
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const client = useClient();
const queryClient = useQueryClient();
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setSelected(null);
setCreating(false);
setEditing(ws.id);
setName(ws.name);
setDescription(ws.description ?? '');
setTemplateIdx(ws.templateIdx ?? 0);
};
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
if (selected === ws.id) setSelected(null);
// Clean up persisted layout
const layoutKey = `ws-layout-${ws.id}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['USER_STATE'], rest);
client.patch('/user/state', { [layoutKey]: null }).catch(() => {});
};
return (
<div className="flex flex-col h-full overflow-y-auto">
@@ -16,25 +57,52 @@ export const WorkspaceListApp = () => {
</div>
<div className="flex flex-col gap-0.5 px-3 pt-3">
{workspaces.map((ws) => (
<Link
<div
key={ws.id}
to={`/workspaces/${ws.id}`}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
selected === ws.id
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/60 hover:bg-duck-dark/5'
}`}
onClick={() => {
setSelected(ws.id);
setCreating(false);
setEditing(null);
}}
>
<LayoutGrid className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{ws.name}</span>
</Link>
<button
type="button"
onClick={(ev) => handleEdit(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
{workspaces.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
)}
<Link
to="/workspaces"
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setCreating(true);
}}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">New workspace</span>
</Link>
</button>
</div>
</div>
);
@@ -1,94 +1,466 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { Plus, Trash2, LayoutGrid, ArrowRight } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState';
import type { WorkspaceDefinition } from '@/components/Workspace';
import { Widget } from 'widgets/Widget';
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
import { Button } from '@/components/ui/button';
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';
export const WorkspaceListScreen = () => {
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const [newName, setNewName] = useState('');
const defaultLayout: LayoutNode = {
type: 'group',
id: 'ws-home-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'ws-home-list', appType: 'workspace-list' }, size: 50 },
{ node: { type: 'panel', id: 'ws-home-right', appType: 'workspace-preview' }, size: 50 },
],
};
const createWorkspace = () => {
const name = newName.trim();
if (!name) return;
const ws: WorkspaceDefinition = {
id: `ws-${Date.now()}`,
name,
cwd: '~',
};
setWorkspaces((prev) => [...prev, ws]);
setNewName('');
};
// --- Layout Templates ---
const deleteWorkspace = (id: string) => {
setWorkspaces((prev) => prev.filter((ws) => ws.id !== id));
};
let tplCounter = 0;
const tplUid = () => `tpl-${++tplCounter}`;
type LayoutTemplate = {
name: string;
layout: () => LayoutNode;
};
const templates: LayoutTemplate[] = [
{
name: 'Single',
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
},
{
name: '2 Columns',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
}),
},
{
name: 'Main + Side',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{
node: {
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
],
}),
},
{
name: 'Sidebar',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
],
}),
},
{
name: '2x2 Grid',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
],
}),
},
{
name: 'Cols + Bottom',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 70,
},
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
],
}),
},
];
// --- Template Thumbnails ---
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
if (node.type === 'panel') {
return <div className="h-full w-full bg-duck-teal/15 border border-duck-teal/20 rounded-sm" />;
}
const isH = node.direction === 'horizontal';
return (
<div className="flex h-full w-full items-start justify-center p-8">
<Widget title="Workspaces" className="w-full max-w-lg">
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
createWorkspace();
}
}}
placeholder="New workspace name..."
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 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"
/>
<Button
onClick={createWorkspace}
disabled={!newName.trim()}
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Plus className="h-4 w-4 mr-1" />
Create
</Button>
</div>
{workspaces.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-duck-dark/40">
<LayoutGrid className="h-8 w-8" />
<p className="text-sm">No workspaces yet</p>
</div>
) : (
<ul className="space-y-1">
{workspaces.map((ws) => (
<li
key={ws.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-duck-dark/5 group"
>
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
<Link to={`/workspaces/${ws.id}`} className="flex-1 min-w-0">
<span className="text-sm font-medium text-duck-dark truncate block">{ws.name}</span>
</Link>
<button
type="button"
onClick={() => deleteWorkspace(ws.id)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Link
to={`/workspaces/${ws.id}`}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</li>
))}
</ul>
)}
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
{node.children.map((child) => (
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
<ThumbnailNode node={child.node} />
</div>
</Widget>
))}
</div>
);
};
const TemplateCell = ({ index }: { index: number }) => {
const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const node = tpl.layout();
const isSelected = selected === index;
return (
<div className="h-full w-full">
<button
type="button"
onClick={() => setSelected(index)}
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
isSelected ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} />
</div>
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span>
</button>
</div>
);
};
// --- Panel: Template Picker (3x2 workspace) ---
const tplPanelLayout: LayoutNode = {
type: 'group',
id: 'tpl-root',
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: 'tpl-row-0',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'tpl-0', appType: 'tpl-0' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-1', appType: 'tpl-1' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-2', appType: 'tpl-2' }, size: 33.34 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: 'tpl-row-1',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'tpl-3', appType: 'tpl-3' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-4', appType: 'tpl-4' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-5', appType: 'tpl-5' }, size: 33.34 },
],
},
size: 50,
},
],
};
const tplRegistry = Object.fromEntries(
templates.map((tpl, i) => [`tpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
);
const TemplatePanel = () => (
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
);
// --- Panel: Name ---
const NamePanel = () => {
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
return (
<div className="h-full w-full">
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<Type className="h-3 w-3" />
Name
</label>
<input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="My Workspace"
autoFocus
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 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"
/>
</div>
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<FileText className="h-3 w-3" />
Description
</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this workspace for?"
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 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"
/>
</div>
</div>
</div>
);
};
// --- Panel: Create ---
const CreatePanel = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [filePath] = useUserState<string>('files/currentPath', '/');
const [, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const isEditing = !!editingId;
const handleSubmit = () => {
const trimmed = name.trim();
if (!trimmed) return;
const desc = description.trim();
if (isEditing) {
setWorkspaces((prev) =>
prev.map((ws) =>
ws.id === editingId
? { ...ws, name: trimmed, description: desc || undefined, templateIdx }
: ws,
),
);
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
const layoutKey = `ws-layout-${editingId}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
} else {
const ws: WorkspaceDefinition = {
id: `ws-${Date.now()}`,
name: trimmed,
cwd,
description: desc || undefined,
templateIdx,
};
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
const layoutKey = `ws-layout-${ws.id}`;
setWorkspaces((prev) => [...prev, ws]);
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
setName('');
setDescription('');
setTemplateIdx(0);
navigate(`/workspaces/${ws.id}`);
}
};
return (
<div className="h-full w-full">
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
<Rocket className="h-8 w-8 text-duck-teal/40" />
<div className="text-center">
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
<p className="text-xs font-mono text-duck-dark/40 mt-1">{cwd}</p>
</div>
<Button
onClick={handleSubmit}
disabled={!name.trim()}
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Plus className="h-4 w-4 mr-1" />
{isEditing ? 'Update Workspace' : 'Create Workspace'}
</Button>
{isEditing && (
<Button
onClick={() => navigate(`/workspaces/${editingId}`)}
variant="outline"
className="cursor-pointer"
>
<ArrowRight className="h-4 w-4 mr-1" />
Open Workspace
</Button>
)}
</div>
</div>
);
};
// --- New Workspace Layout ---
const newWsLayout: LayoutNode = {
type: 'group',
id: 'new-ws-root',
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: 'new-ws-top',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser' }, size: 70 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: 'new-ws-bottom',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-ws-template', appType: 'new-ws-template' }, size: 70 },
{ node: { type: 'panel', id: 'new-ws-create', appType: 'new-ws-create' }, size: 30 },
],
},
size: 50,
},
],
};
const newWsRegistry = {
...appRegistry,
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
};
const NewWorkspaceForm = () => (
<WorkspaceLayout layout={newWsLayout} onLayoutChange={() => {}} registry={newWsRegistry} />
);
const WorkspacePreviewEmpty = () => {
const [creating, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [editingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
if (creating || editingId) return <NewWorkspaceForm />;
return (
<div className="h-full w-full">
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-duck-dark/30">
<LayoutGrid className="h-10 w-10" />
<p className="text-sm">Select a workspace or create a new one</p>
<Button onClick={() => setCreating(true)} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
<Plus className="h-4 w-4 mr-1" />
New Workspace
</Button>
</div>
</div>
);
};
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
};
const WorkspacePreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
if (!workspace) return <WorkspacePreviewEmpty />;
return (
<div className="relative h-full w-full">
<WorkspacePreviewInner key={workspace.id} workspace={workspace} />
<Link
to={`/workspaces/${workspace.id}`}
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
>
<ArrowRight className="h-16 w-16 text-duck-teal/0 group-hover:text-duck-teal/60 transition-colors" />
</Link>
</div>
);
};
const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } };
export const WorkspaceListScreen = () => {
const [layout, setLayout, isLoaded] = useUserState<LayoutNode>('ws-layout-workspaces', defaultLayout);
if (!isLoaded) return null;
return (
<div className="h-full w-full">
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={registry} />
</div>
);
};
@@ -0,0 +1,6 @@
export const SELECTED_WORKSPACE_KEY = 'SELECTED_WORKSPACE';
export const CREATING_WORKSPACE_KEY = 'CREATING_WORKSPACE';
export const EDITING_WORKSPACE_KEY = 'EDITING_WORKSPACE';
export const NEW_WS_NAME_KEY = 'NEW_WS_NAME';
export const NEW_WS_DESC_KEY = 'NEW_WS_DESC';
export const NEW_WS_TEMPLATE_KEY = 'NEW_WS_TEMPLATE';
+3 -3
View File
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/components/ui/ThemeProvider';
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
import { I18nBridge } from '@/lib/I18nBridge';
import { Toaster } from '@/components/ui/toaster';
import { Toaster as Sonner } from 'sonner';
@@ -34,7 +34,7 @@ const elem = document.getElementById('root')!;
const app = (
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="officer-theme">
<ColorModeProvider>
<I18nBridge>
<TooltipProvider>
<Toaster />
@@ -42,7 +42,7 @@ const app = (
<App />
</TooltipProvider>
</I18nBridge>
</ThemeProvider>
</ColorModeProvider>
</QueryClientProvider>
</StrictMode>
);
@@ -15,7 +15,7 @@ export type UserSettings = {
defaultModel: string | null;
};
appearance: {
theme: string;
colorMode: 'light' | 'dark';
colorTheme: string;
};
languages: {
@@ -44,7 +44,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
defaultModel: null,
},
appearance: {
theme: 'light',
colorMode: 'light',
colorTheme: 'DuckPond',
},
languages: {
+2 -2
View File
@@ -1,13 +1,13 @@
import { useChatSessions } from '@/state/useChatSessions';
import { usePlans } from './usePlans';
import { useSettings } from './useSettings';
import { useThemeSync } from './useThemeSync';
import { useColorModeSync } from './useThemeSync';
export const useInitialData = () => {
const { sessions } = useChatSessions();
const { plans } = usePlans();
const { settings } = useSettings();
useThemeSync();
useColorModeSync();
return { sessions, plans, settings };
};
+5 -5
View File
@@ -1,13 +1,13 @@
import { useEffect } from 'react';
import { useTheme } from '@/components/ui/ThemeProvider';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useSettings } from './useSettings';
export const useThemeSync = () => {
export const useColorModeSync = () => {
const { settings } = useSettings();
const { setTheme } = useTheme();
const { setColorMode } = useColorMode();
useEffect(() => {
if (!settings) return;
setTheme('light');
}, [settings?.appearance?.colorTheme]);
setColorMode(settings.appearance.colorMode);
}, [settings?.appearance?.colorMode]);
};
+3 -3
View File
@@ -6,14 +6,14 @@ import type { UserState } from './types/user-settings';
const QUERY_KEY = ['USER_STATE'];
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {} } = useQuery<UserState>({
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/state'),
@@ -36,5 +36,5 @@ export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | (
[key, defaultValue, queryClient],
);
return [value, setValue];
return [value, setValue, isSuccess];
}
+6
View File
@@ -213,6 +213,12 @@
}
.dark {
/* Duck brand — lightened for dark-mode contrast */
--duck-teal: #22d3ee;
--duck-dark: #f1f5f9;
--duck-forest: #4ade80;
--duck-beige: #292524;
/* Page background */
--pixel-grid-color: rgba(200, 230, 210, 0.08);
+42 -34
View File
@@ -1,34 +1,38 @@
/* File Viewer — Markdown Prose */
.file-viewer-md {
color: #14532d;
color: hsl(var(--foreground));
font-size: 0.95rem;
line-height: 1.75;
}
.file-viewer-md h1 {
color: hsl(var(--foreground));
font-size: 2em;
font-weight: 700;
margin: 1.5em 0 0.5em;
padding-bottom: 0.3em;
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
border-bottom: 2px solid hsl(var(--border));
letter-spacing: -0.02em;
}
.file-viewer-md h2 {
color: hsl(var(--foreground));
font-size: 1.5em;
font-weight: 600;
margin: 1.4em 0 0.4em;
padding-bottom: 0.25em;
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
border-bottom: 1px solid hsl(var(--border));
}
.file-viewer-md h3 {
color: hsl(var(--foreground));
font-size: 1.25em;
font-weight: 600;
margin: 1.2em 0 0.4em;
}
.file-viewer-md h4, .file-viewer-md h5, .file-viewer-md h6 {
color: hsl(var(--foreground));
font-size: 1.05em;
font-weight: 600;
margin: 1em 0 0.3em;
@@ -39,27 +43,26 @@
}
.file-viewer-md a {
color: #0891b2;
color: var(--duck-teal);
text-decoration: underline;
text-underline-offset: 2px;
}
.file-viewer-md a:hover {
color: #0e7490;
opacity: 0.8;
}
.file-viewer-md strong {
font-weight: 600;
color: #14532d;
}
.file-viewer-md blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 3px solid #0891b2;
background: rgba(8, 145, 178, 0.05);
border-left: 3px solid var(--duck-teal);
background: color-mix(in srgb, var(--duck-teal) 5%, transparent);
border-radius: 0 0.5rem 0.5rem 0;
color: #166534;
color: hsl(var(--muted-foreground));
}
.file-viewer-md ul, .file-viewer-md ol {
@@ -72,13 +75,13 @@
}
.file-viewer-md li::marker {
color: #0891b2;
color: var(--duck-teal);
}
.file-viewer-md hr {
border: none;
height: 1px;
background: rgba(20, 83, 45, 0.12);
background: hsl(var(--border));
margin: 2em 0;
}
@@ -90,20 +93,21 @@
}
.file-viewer-md th {
background: rgba(8, 145, 178, 0.08);
color: hsl(var(--muted-foreground));
background: color-mix(in srgb, var(--duck-teal) 8%, transparent);
font-weight: 600;
text-align: left;
padding: 0.5em 0.75em;
border: 1px solid rgba(20, 83, 45, 0.12);
border: 1px solid hsl(var(--border));
}
.file-viewer-md td {
padding: 0.5em 0.75em;
border: 1px solid rgba(20, 83, 45, 0.08);
border: 1px solid hsl(var(--border));
}
.file-viewer-md tr:nth-child(even) {
background: rgba(20, 83, 45, 0.02);
background: hsl(var(--muted) / 0.3);
}
.file-viewer-md img {
@@ -113,41 +117,45 @@
}
.file-viewer-md input[type="checkbox"] {
accent-color: #0891b2;
accent-color: var(--duck-teal);
margin-right: 0.5em;
}
/* Skills — Markdown Prose */
.skill-md {
color: #14532d;
color: hsl(var(--foreground));
font-size: 0.95rem;
line-height: 1.75;
}
.skill-md h1 {
color: hsl(var(--foreground));
font-size: 2em;
font-weight: 700;
margin: 1.5em 0 0.5em;
padding-bottom: 0.3em;
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
border-bottom: 2px solid hsl(var(--border));
letter-spacing: -0.02em;
}
.skill-md h2 {
color: hsl(var(--foreground));
font-size: 1.5em;
font-weight: 600;
margin: 1.4em 0 0.4em;
padding-bottom: 0.25em;
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
border-bottom: 1px solid hsl(var(--border));
}
.skill-md h3 {
color: hsl(var(--foreground));
font-size: 1.25em;
font-weight: 600;
margin: 1.2em 0 0.4em;
}
.skill-md h4, .skill-md h5, .skill-md h6 {
color: hsl(var(--foreground));
font-size: 1.05em;
font-weight: 600;
margin: 1em 0 0.3em;
@@ -158,27 +166,26 @@
}
.skill-md a {
color: #0891b2;
color: var(--duck-teal);
text-decoration: underline;
text-underline-offset: 2px;
}
.skill-md a:hover {
color: #0e7490;
opacity: 0.8;
}
.skill-md strong {
font-weight: 600;
color: #14532d;
}
.skill-md blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 3px solid #0891b2;
background: rgba(8, 145, 178, 0.05);
border-left: 3px solid var(--duck-teal);
background: color-mix(in srgb, var(--duck-teal) 5%, transparent);
border-radius: 0 0.5rem 0.5rem 0;
color: #166534;
color: hsl(var(--muted-foreground));
}
.skill-md ul, .skill-md ol {
@@ -191,14 +198,14 @@
}
.skill-md li::marker {
color: #0891b2;
color: var(--duck-teal);
}
.skill-md code {
padding: 0.15em 0.4em;
border-radius: 0.25rem;
background: rgba(8, 145, 178, 0.1);
color: #0891b2;
background: color-mix(in srgb, var(--duck-teal) 10%, transparent);
color: var(--duck-teal);
font-size: 0.85em;
font-family: ui-monospace, monospace;
}
@@ -224,7 +231,7 @@
.skill-md hr {
border: none;
height: 1px;
background: rgba(20, 83, 45, 0.12);
background: hsl(var(--border));
margin: 2em 0;
}
@@ -236,20 +243,21 @@
}
.skill-md th {
background: rgba(8, 145, 178, 0.08);
color: hsl(var(--muted-foreground));
background: color-mix(in srgb, var(--duck-teal) 8%, transparent);
font-weight: 600;
text-align: left;
padding: 0.5em 0.75em;
border: 1px solid rgba(20, 83, 45, 0.12);
border: 1px solid hsl(var(--border));
}
.skill-md td {
padding: 0.5em 0.75em;
border: 1px solid rgba(20, 83, 45, 0.08);
border: 1px solid hsl(var(--border));
}
.skill-md tr:nth-child(even) {
background: rgba(20, 83, 45, 0.02);
background: hsl(var(--muted) / 0.3);
}
.skill-md img {
@@ -259,6 +267,6 @@
}
.skill-md input[type="checkbox"] {
accent-color: #0891b2;
accent-color: var(--duck-teal);
margin-right: 0.5em;
}