frontmatter in skills files

This commit is contained in:
2026-02-18 01:23:22 +00:00
parent 030cf9fbd3
commit c2660cd125
16 changed files with 959 additions and 506 deletions
@@ -18,10 +18,9 @@ export function LandingPage() {
</div>
</header>
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-12">
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-2">
{registrationOpen ? <Bootstrap /> : <Login />}
</div>
</div>
);
}
@@ -7,17 +7,20 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
export function Login() {
const [isSubmitting, setIsSubmitting] = useState(false);
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
const { signin } = useAuth();
const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!isValid || isSubmitting) return;
setIsSubmitting(true);
setDuckHidden(true);
try {
await signin({ email: state.email!, password: state.password! });
window.location.href = '/';
@@ -31,7 +34,7 @@ export function Login() {
};
return (
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
<Card className="md:py-6 md:px-24 flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
<div className="text-duck-dark/60">Sign in to your account</div>
@@ -7,7 +7,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
<div className="relative overflow-hidden h-dvh outline-none inset-0">
<section className="relative h-dvh snap-start overflow-hidden">
<Background />
<div className="absolute inset-0 z-520">
<div className="absolute inset-0 z-20">
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
{children}
</div>
@@ -1,8 +1,11 @@
import { DuckAvatar } from "./DuckAvatar";
import { PixelGrid } from "@/components/PixelGrid";
import { useGlobal } from 'hooks/useGlobal';
import landscapebg from './landscape1.jpg';
export function Background() {
const [duckHidden] = useGlobal('DUCK_HIDDEN', false);
return (
<div
className="absolute inset-0 z-0"
@@ -13,7 +16,7 @@ export function Background() {
}}
>
<PixelGrid />
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
</div>
);
};
@@ -1,77 +1,58 @@
import { Link } from 'react-router';
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/Card';
import { MessageSquare, Trash2 } from 'lucide-react';
import { Widget } from '@/components/Widget';
import { useChatSessions } from '@/state/useChatSessions';
import { useUserState } from '@/state/useUserState';
export const ChatHistory = () => {
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
const { sessions, deleteSession } = useChatSessions();
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<Link to="/chat" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
Chat History
</Link>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
</div>
{!collapsed && (
<>
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
) : (
<ul className="space-y-0.5">
{sessions.map((session) => (
<li
key={`${session.provider}-${session.id}`}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex items-center gap-2 flex-1 min-w-0"
<Widget title="Chat History">
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
) : (
<ul className="space-y-0.5">
{sessions.map((session) => (
<li
key={`${session.provider}-${session.id}`}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex items-center gap-2 flex-1 min-w-0"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-1.5 font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-1.5 font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
</span>
</div>
</Link>
<button
onClick={() => deleteSession(session.provider, session.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>
</li>
))}
</ul>
)}
</div>
</>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
</span>
</div>
</Link>
<button
onClick={() => deleteSession(session.provider, session.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>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
</Widget>
);
};
@@ -1,12 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, Link } from 'react-router';
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X, ChevronDown, ChevronUp } from 'lucide-react';
import { useNavigate } from 'react-router';
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry, Breadcrumb } from 'widgets/FileBrowser';
import { useRecentFiles } from './state/useRecentFiles';
import { usePinnedFiles } from './state/usePinnedFiles';
import { useUserState } from '@/state/useUserState';
import { Card } from '@/components/Card';
import { Widget } from '@/components/Widget';
type Tab = 'browse' | 'recent' | 'pinned';
@@ -22,7 +21,6 @@ export const FileBrowser = () => {
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
const [collapsed, setCollapsed] = useUserState('widget:fileBrowser:collapsed', true);
const [tab, setTab] = useState<Tab>('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState<DirEntry[]>([]);
@@ -72,171 +70,154 @@ export const FileBrowser = () => {
});
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<Link to="/files" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
File Browser
</Link>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<Widget title="File Browser">
{/* Tab bar + search */}
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
{!collapsed && (
<>
{/* Tab bar + search */}
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-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"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-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"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
searching ? (
{/* Content */}
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
searching ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
) : (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
/>
))}
</ul>
)
) : (
<>
{tab === 'browse' && (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
) : sorted.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
{sorted.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
)}
{tab === 'recent' && (
<div className="pt-2">
{recents.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
) : (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)
) : (
<>
{tab === 'browse' && (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : sorted.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{sorted.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
)}
)}
</div>
)}
{tab === 'recent' && (
<div className="pt-2">
{recents.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
) : (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
{tab === 'pinned' && (
<div className="pt-2">
{pinned.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
) : (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
{tab === 'pinned' && (
<div className="pt-2">
{pinned.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
) : (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</Card>
</div>
</div>
</Widget>
);
};
@@ -1,9 +1,8 @@
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { useNavigate, Link } from 'react-router';
import { useNavigate } from 'react-router';
import {
Send,
ChevronDown,
ChevronUp,
Check,
Paperclip,
Link as LinkIcon,
@@ -14,9 +13,8 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { Widget } from '@/components/Widget';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import {
DropdownMenu,
@@ -39,7 +37,6 @@ export const ChatLauncher = () => {
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [collapsed, setCollapsed] = useUserState('widget:chatLauncher:collapsed', true);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [urlInput, setUrlInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -153,172 +150,154 @@ export const ChatLauncher = () => {
}, [input]);
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<Link
to="/chat/new"
className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline"
>
Start Chat
</Link>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
</div>
{!collapsed && (
<>
<div className="p-4 pb-2 pt-1">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
>
{a.loading ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : a.type === 'image' && a.dataUrl ? (
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
) : a.type === 'image' ? (
<Image className="h-3 w-3 shrink-0" />
) : (
<LinkIcon className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
</span>
<button
type="button"
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
>
<Paperclip className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="z-[600]">
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
<Image className="mr-2 h-4 w-4" />
Image
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Text File
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
PDF
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
<LinkIcon className="mr-2 h-4 w-4" />
Webpage URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) handleAttachImage(file);
ev.target.value = '';
}}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) handleAttachImage(file);
return;
}
}
}}
placeholder="What do you want to work on now?"
rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
/>
<Button
onClick={handleSubmit}
disabled={!input.trim()}
size="icon"
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
<>
<Widget title="Start Chat">
<div className="p-4 pb-2 pt-1">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
{(['claude', 'opencode'] as const).map((value) => (
{a.loading ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : a.type === 'image' && a.dataUrl ? (
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
) : a.type === 'image' ? (
<Image className="h-3 w-3 shrink-0" />
) : (
<LinkIcon className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
</span>
<button
key={value}
onClick={() => {
setProvider(value);
setModel(null);
}}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
provider === value
? 'bg-white text-duck-dark shadow-sm'
: 'text-duck-dark/50 hover:text-duck-dark/70'
}`}
type="button"
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
{value === 'claude' ? 'Claude' : 'OpenCode'}
<X className="h-3 w-3" />
</button>
))}
</div>
{models.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
{models.map((m) => (
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
<Check
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="font-bold">{m.name}</span>
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
))}
</div>
</>
)}
</Card>
)}
<div className="flex items-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
>
<Paperclip className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="z-[600]">
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
<Image className="mr-2 h-4 w-4" />
Image
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Text File
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
PDF
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
<LinkIcon className="mr-2 h-4 w-4" />
Webpage URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) handleAttachImage(file);
ev.target.value = '';
}}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) handleAttachImage(file);
return;
}
}
}}
placeholder="What do you want to work on now?"
rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
/>
<Button
onClick={handleSubmit}
disabled={!input.trim()}
size="icon"
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
{(['claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => {
setProvider(value);
setModel(null);
}}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
provider === value
? 'bg-white text-duck-dark shadow-sm'
: 'text-duck-dark/50 hover:text-duck-dark/70'
}`}
>
{value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
{models.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
{models.map((m) => (
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
<Check
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="font-bold">{m.name}</span>
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Widget>
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
<DialogContent className="sm:max-w-md z-[700]">
@@ -351,6 +330,6 @@ export const ChatLauncher = () => {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
};
@@ -1,19 +1,26 @@
import { Workspace, createWorkspaceDefaults } from '@/components/Workspace';
import { useUserState } from '@/state/useUserState';
import { ChatLauncher } from './ChatLauncher';
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
import { Catalog } from 'sounds';
const WIDGET_IDS = ['chat-launcher', 'file-browser', 'chat-history', 'sound-library'] as const;
const DEFAULTS = createWorkspaceDefaults([...WIDGET_IDS]);
export const HomeScreen = () => {
const [state, setState] = useUserState('home-layout', DEFAULTS);
return (
<div className="flex flex-col md:flex-row gap-4 md:gap-8 h-full p-4 pt-6 md:p-8 md:pt-12 overflow-y-auto">
<div className="flex flex-col gap-8 flex-1 min-w-0">
<ChatLauncher />
<FileBrowser />
</div>
<div className="flex flex-col gap-8 flex-1 min-w-0">
<ChatHistory />
<Catalog />
</div>
</div>
<Workspace
widgets={{
'chat-launcher': <ChatLauncher />,
'file-browser': <FileBrowser />,
'chat-history': <ChatHistory />,
'sound-library': <Catalog />,
}}
state={state}
onChange={setState}
/>
);
};
@@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { toast } from 'sonner';
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search } from 'lucide-react';
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
@@ -22,6 +22,7 @@ type ResourceSummary = {
type ResourceDetail = ResourceSummary & {
body: string;
rawFrontmatter: string;
filePath: string;
chatSessionId: string | null;
};
@@ -109,6 +110,25 @@ const ResourceChat = ({
);
};
const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const [open, setOpen] = useState(false);
return (
<div className="mb-4 rounded border border-duck-dark/10 bg-duck-dark/3 text-sm">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="text-xs font-medium">Frontmatter</span>
</button>
{open && (
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
)}
</div>
);
};
export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) => {
const client = useClient();
const qc = useQueryClient();
@@ -315,6 +335,7 @@ export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) =>
)}
</div>
<div className="overflow-y-auto flex-1 p-6">
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
{detail?.body ? (
<article className="skill-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>