every plugin route renders a workspace, and it is not a rule you can forget

an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
// Poll the registry (harness task files + announced detached jobs).
useEffect(() => {
let alive = true;
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
const tick = () =>
get<Registry>('/activity/tasks')
.then((r) => {
if (alive) {
setReg(r);
setRegLoaded(true);
}
})
.catch(() => {});
tick();
const iv = setInterval(tick, POLL_MS);
return () => { alive = false; clearInterval(iv); };
return () => {
alive = false;
clearInterval(iv);
};
}, []);
// The row backing the open id, and the stream query it implies. A string rather than the row object,
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
const row = selectedId
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
: undefined;
const query = !row
? null
: row.source === 'harness'
? `task=${encodeURIComponent(row.id)}`
: `path=${encodeURIComponent(row.path)}`;
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
useEffect(() => {
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
try {
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch { /* ignore */ }
else if (d.kind === 'line' && typeof d.text === 'string')
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch {
/* ignore */
}
};
return () => es.close();
}, [query, token]);
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [lines]);
const rowCls = (active: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
@@ -68,20 +90,36 @@ export const ActivityScreen = () => {
<ActivityIcon size={16} className="text-primary" /> Activity
</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Background tasks
</div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => (
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
<Link
key={t.id}
to={`/activity/${encodeURIComponent(t.id)}`}
className={rowCls(selectedId === t.id)}
title={t.cwd}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
/>
<span className="truncate font-mono text-xs">{t.id}</span>
</Link>
))}
{reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detached
</div>
)}
{reg.detached.map((d) => (
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
<Link
key={d.id}
to={`/activity/${encodeURIComponent(d.id)}`}
className={rowCls(selectedId === d.id)}
title={d.path}
>
<FileText size={13} className="shrink-0" />
<span className="truncate">{d.id}</span>
</Link>
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
{progress.status ? ` (${progress.status})` : ''}
</span>
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
<span className="shrink-0 pl-2">
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
</span>
</div>
{typeof progress.pct === 'number' && (
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
/>
</div>
)}
</div>
)}
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
>
{lines.length === 0 ? (
<span className="text-muted-foreground">
{query ? 'waiting for output…' : regLoaded ? (
{query ? (
'waiting for output…'
) : regLoaded ? (
<>
no run called <span className="font-mono">{selectedId}</span> is in the registry it finished, or it never started.{' '}
<Link to="/activity" className="underline">Back to the list</Link>
no run called <span className="font-mono">{selectedId}</span> is in the registry it finished, or
it never started.{' '}
<Link to="/activity" className="underline">
Back to the list
</Link>
</>
) : 'loading…'}
) : (
'loading…'
)}
</span>
) : (
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-words">
{l}
</div>
))
)}
</div>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a task to follow its live output
</div>
)}
</main>
</div>
@@ -87,7 +87,13 @@ export const TabPreview = () => {
<div className="flex h-full flex-col overflow-hidden">
{/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => void refetch()}
disabled={isFetching}
>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<form
@@ -104,7 +110,13 @@ export const TabPreview = () => {
placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
/>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
type="submit"
disabled={isNavigating || !navUrl.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</form>
@@ -143,7 +155,13 @@ export const TabPreview = () => {
placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono"
/>
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
type="submit"
disabled={isEvaluating || !evalExpr.trim()}
>
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button>
</form>
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment.
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
const RecipientInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
const client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false);
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
return;
}
const t = setTimeout(() => {
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
client
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
.then(setSuggestions)
.catch(() => {});
}, 180);
return () => clearTimeout(t);
}, [seg]);
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
inlineMap.current.clear();
nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
if (editorRef.current)
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
const named = (f: File) =>
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
@@ -248,7 +263,8 @@ export const ComposeModal = () => {
}
};
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
const fmtSize = (n: number) =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
if (!draft) return null;
@@ -315,7 +331,9 @@ export const ComposeModal = () => {
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>}
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>
)}
<div
ref={editorRef}
contentEditable
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
>
<Paperclip className="h-4 w-4" />
</button>
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
<button
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
@@ -382,12 +403,21 @@ export const ComposeModal = () => {
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
// is a follow-up: store the Message-Id header on ingest.)
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
export const replyDraft = (m: {
from: string;
subject: string;
date: string;
text?: string;
snippet?: string;
}): ComposeDraft => {
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim();
const quoted = original
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
.split('\n')
.map((l) => `> ${l}`)
.join('\n')}`
: '';
return { to: addr, subject, body: quoted };
};
@@ -63,8 +63,13 @@ type MessagePanelProps = {
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) {
return (
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
<button
onClick={onToggle}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
>
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
{senderName(message.from)}
</span>
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
@@ -114,7 +119,11 @@ const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: Me
</div>
)}
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
{message.html ? (
<HtmlBody html={message.html} />
) : (
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
)}
</div>
);
};
@@ -209,7 +218,11 @@ export const EmailReader = () => {
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && (
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
<FileViewerProvider
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader />
</div>
@@ -1,8 +1,16 @@
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
import { useParams, Link } from 'react-router';
import {
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
ChevronRight, Wrench,
ArrowLeft,
CheckCircle2,
AlertCircle,
Loader2,
StopCircle,
AlertTriangle,
Clock,
Square,
ChevronRight,
Wrench,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
@@ -53,21 +61,58 @@ type JobData = {
// Output entries for the right panel
type OutputEntry =
| { id: string; type: 'text'; text: string }
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
| {
id: string;
type: 'tool';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
output?: string;
isError?: boolean;
};
type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| {
jobId: string;
type: 'step:start';
stepIndex: number;
taskName: string;
iteration?: { current: number; total: number; label: string };
}
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| {
jobId: string;
type: 'step:parallel';
stepIndex: number;
taskName: string;
iterations: string[];
concurrency: number;
}
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
| {
jobId: string;
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
stepIndex: number;
iterationLabel?: string;
}
| {
jobId: string;
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
stepIndex: number;
iterationLabel?: string;
}
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
| { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' }
@@ -86,7 +131,7 @@ const formatElapsed = (seconds: number) => {
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
/** Build a unique key for grouping output by step/iteration */
const outputKey = (stepIndex: number, iterationLabel?: string) =>
@@ -128,15 +173,12 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
{entry.output !== undefined && (
<StatusIcon
status={entry.isError ? 'error' : 'complete'}
className="h-3 w-3 shrink-0 ml-auto"
/>
<StatusIcon status={entry.isError ? 'error' : 'complete'} className="h-3 w-3 shrink-0 ml-auto" />
)}
{entry.output === undefined && (
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
)}
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
{entry.output === undefined && <Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />}
<ChevronRight
className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
</button>
{expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
@@ -149,7 +191,9 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{entry.output !== undefined && (
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
<pre
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
>
{entry.output}
</pre>
</div>
@@ -194,9 +238,18 @@ const DEFAULT_LAYOUT: LayoutNode = {
const StepsPanel = () => {
const ctx = useJobPanel();
const {
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
skippedItems, outputMap,
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
} = ctx;
return (
@@ -261,16 +314,16 @@ const StepsPanel = () => {
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
{it.cost && (
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">
{formatCost(it.cost.totalUSD)}
</span>
)}
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
</button>
);
})}
{skippedItems.length > 0 && (
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
{skippedItems.length} skipped
</div>
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
)}
</div>
)}
@@ -294,7 +347,9 @@ const OutputPanel = () => {
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
{selectedKey && (
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
<span className="text-xs text-duck-dark/40 truncate">
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
</span>
)}
</div>
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
@@ -316,9 +371,7 @@ const OutputPanel = () => {
</div>
);
}
return (
<ToolCallEntry key={entry.id} entry={entry} />
);
return <ToolCallEntry key={entry.id} entry={entry} />;
})}
{selectedStreaming && (
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
@@ -365,7 +418,10 @@ export const PipelineJobDetail = () => {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const addCost = useCallback((cost: Cost) => {
@@ -390,9 +446,10 @@ export const PipelineJobDetail = () => {
const arr = prev.get(key);
if (!arr) return prev;
const next = new Map(prev);
next.set(key, arr.map((e) =>
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
));
next.set(
key,
arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
);
return next;
});
}, []);
@@ -461,181 +518,205 @@ export const PipelineJobDetail = () => {
};
}, [id, job?.status]);
const handleEvent = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case 'job:state':
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
const handleEvent = useCallback(
(msg: ServerMessage) => {
switch (msg.type) {
case 'job:state':
if (
msg.status === 'completed' ||
msg.status === 'failed' ||
msg.status === 'stopped' ||
msg.status === 'interrupted'
) {
setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
if (id) {
client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
}
}
if (msg.progress) {
const p = msg.progress as ProgressData;
if (p?.steps) setSteps(p.steps);
}
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'step:start': {
setParallelStep(null);
setActiveStepIndex(msg.stepIndex);
const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key);
break;
}
case 'step:complete':
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
setParallelStep(null);
setActiveStepIndex(-1);
if (msg.cost) addCost(msg.cost);
// Flush any remaining stream buffer for this step
flushStreamBuffer(outputKey(msg.stepIndex));
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
setActiveStepIndex(msg.stepIndex);
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
// Auto-select first iteration
if (autoFollowRef.current && msg.iterations.length > 0) {
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
}
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'assistant:delta': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const buf = streamBuffers.current;
buf.set(key, (buf.get(key) ?? '') + msg.text);
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
break;
}
case 'assistant:text': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
}
streamBuffers.current.delete(key);
setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
break;
}
case 'tool:start': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
});
break;
}
case 'tool:result': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
break;
}
case 'pipeline:complete':
setTotalCost(msg.totalCost);
setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
setCompletedSteps((prev) => {
const next = new Set(prev);
setSteps((s) => {
s.forEach((_, i) => next.add(i));
return s;
});
return next;
});
setActiveStepIndex(-1);
setParallelStep(null);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
}
}
if (msg.progress) {
const p = msg.progress as ProgressData;
if (p?.steps) setSteps(p.steps);
}
break;
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
break;
case 'step:start': {
setParallelStep(null);
setActiveStepIndex(msg.stepIndex);
const key = msg.iteration
? outputKey(msg.stepIndex, msg.iteration.label)
: outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key);
break;
case 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
},
[id, stopTimer, addCost, appendOutput, updateToolOutput],
);
case 'step:complete':
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
setParallelStep(null);
setActiveStepIndex(-1);
if (msg.cost) addCost(msg.cost);
// Flush any remaining stream buffer for this step
flushStreamBuffer(outputKey(msg.stepIndex));
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
setActiveStepIndex(msg.stepIndex);
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
// Auto-select first iteration
if (autoFollowRef.current && msg.iterations.length > 0) {
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
}
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'assistant:delta': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const buf = streamBuffers.current;
buf.set(key, (buf.get(key) ?? '') + msg.text);
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
break;
}
case 'assistant:text': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
}
const flushStreamBuffer = useCallback(
(key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
break;
}
case 'tool:start': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
break;
}
case 'tool:result': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
break;
}
case 'pipeline:complete':
setTotalCost(msg.totalCost);
setLiveStatus('done');
setCompletedSteps((prev) => {
const next = new Set(prev);
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
return next;
});
setActiveStepIndex(-1);
setParallelStep(null);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
}
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
break;
case 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
}
}, [appendOutput]);
},
[appendOutput],
);
const handleStop = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
@@ -648,10 +729,13 @@ export const PipelineJobDetail = () => {
setSelectedKey(key);
}, []);
const panelComponents: PanelComponents = useMemo(() => ({
steps: StepsPanel,
output: OutputPanel,
}), []);
const panelComponents: PanelComponents = useMemo(
() => ({
steps: StepsPanel,
output: OutputPanel,
}),
[],
);
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
const isRunning = displayStatus === 'running';
@@ -659,37 +743,65 @@ export const PipelineJobDetail = () => {
const jobDone = !isRunning && !isLive;
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency,
iterations: job.progress.parallel.iterations.map((it) => ({
label: it.label,
status: it.status as IterationStatus['status'],
})),
} : null);
const displayParallel: ParallelStep | null =
parallelStep ??
(jobDone && job?.progress?.parallel
? {
stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency,
iterations: job.progress.parallel.iterations.map((it) => ({
label: it.label,
status: it.status as IterationStatus['status'],
})),
}
: null);
const panelCtx = useMemo<JobPanelContext>(() => ({
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
}), [
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, job?.status, selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap,
]);
const panelCtx = useMemo<JobPanelContext>(
() => ({
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus: job?.status ?? 'pending',
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
streamingMap,
outputPanelRef,
}),
[
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
job?.status,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
streamingMap,
],
);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
);
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
}
if (!job) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs
</Link>
</div>
);
}
@@ -699,7 +811,9 @@ export const PipelineJobDetail = () => {
? elapsed
: job.startedAt && job.completedAt
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
: elapsed > 0 ? elapsed : null;
: elapsed > 0
? elapsed
: null;
return (
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
@@ -758,7 +872,9 @@ export const PipelineJobDetail = () => {
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
<span className="text-sm text-red-700 dark:text-red-300">
{job.error ?? 'An error occurred during execution'}
</span>
</div>
</Card>
)}
@@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
</div>
);
};
@@ -1,4 +1,4 @@
import { PixelGrid } from "@/components/PixelGrid";
import { PixelGrid } from '@/components/PixelGrid';
const landscapebg = '/landscape1.webp';
export function Background() {
@@ -14,4 +14,4 @@ export function Background() {
<PixelGrid />
</div>
);
};
}
@@ -13,7 +13,14 @@ type BugReportDialogProps = {
onSubmit: (description: string) => void;
};
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
export const BugReportDialog = ({
open,
capturing,
submitting,
screenshot,
onClose,
onSubmit,
}: BugReportDialogProps) => {
const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -0,0 +1,38 @@
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
//
// ── The rule, and why it is shape rather than policy ──
//
// Every plugin route renders a Workspace with at least one panel. A plugin that exported a component
// could render anything at all — a bare div, a full-page form, its own navigation — and the platform
// would be a shell hosting strangers' layouts rather than one application. So a plugin does not get to
// render the screen: it contributes panels and says how they are arranged, and this renders the
// Workspace around them.
//
// Non-compliance is therefore not refused, it is unrepresentable. There is nowhere to put a screen.
//
// `locked`, like every core screen: a plugin's layout is its author's design, not a workspace the user
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
// something else falls back rather than rendering another plugin's panel inside this one.
export function PluginScreen({
appName,
panels,
layout,
}: {
appName: string;
panels: AppRegistryMeta[];
layout: LayoutNode;
}) {
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
const allowed = panels.map((panel) => panel.key);
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked appTypes={{ allowed, fallback: allowed[0] ?? '' }} />
</div>
);
}
@@ -51,7 +51,9 @@ export const ApifyConfig = () => {
<div className="grid gap-5">
{status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
<div
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
/>
<span className="text-sm text-duck-dark dark:text-foreground">
{status.configured ? 'API token configured' : 'Not configured'}
</span>
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Get your token at{' '}
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.apify.com/account/integrations"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
console.apify.com/account/integrations
</a>
</span>
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
{' — '}
<a href="/browser" className="text-duck-teal underline">view tabs</a>
<a href="/browser" className="text-duck-teal underline">
view tabs
</a>
</p>
</div>
</div>
@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<li>
Open{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
chrome://extensions
</code>{' '}
in Chrome
</li>
<li>
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Regenerate
</Button>
@@ -166,8 +165,8 @@ export const BrowserRelay = () => {
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
the tab is connected. Then go to{' '}
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
means the tab is connected. Then go to{' '}
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
/browser <ExternalLink className="h-3 w-3" />
</a>{' '}
@@ -189,10 +188,11 @@ type CredentialRowProps = {
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
<code className="flex-1 text-xs truncate">
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
</code>
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
<button
onClick={onCopy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button>
</div>
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return (
<div
key={account.id}
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
>
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1">
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li>
<li><strong>Google Calendar API</strong></li>
<li>
<strong>Gmail API</strong>
</li>
<li>
<strong>Google Calendar API</strong>
</li>
</ul>
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Set <strong>App name</strong> to your organization name or &quot;Officer&quot;</li>
<li>Set <strong>User support email</strong> to your admin email</li>
<li>Add your admin email under <strong>Developer contact information</strong></li>
<li>Click <strong>Save</strong></li>
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
</li>
<li>
Set <strong>User support email</strong> to your admin email
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
</li>
<li>
Click <strong>Save</strong>
</li>
</ul>
</li>
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
If your team uses Google Workspace, select <strong>Internal</strong> no verification needed
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
</li>
</ul>
</li>
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.scope}
</code>{' '}
— {s.description}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
</li>
))}
</ul>
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li>
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Application type: <strong>Web application</strong></li>
<li>
Application type: <strong>Web application</strong>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>
Authorized redirect URIs: add{' '}
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
{redirectUri}
</code>
</li>
<li>Click <strong>Create</strong></li>
<li>
Click <strong>Create</strong>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li>
</ol>
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
</span>
</div>
);
@@ -87,19 +87,25 @@ export const AIModels = () => {
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -1,7 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onended = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
audio.onerror = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
await audio.play();
setListening('playing');
} catch {
@@ -120,7 +136,9 @@ export const VoicePreference = () => {
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="flex items-center gap-2">
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
<SelectValue placeholder="Server default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
<SelectItem value={SERVER_DEFAULT}>
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
</SelectItem>
{groups.length > 0
? groups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
<SelectItem key={v} value={v}>
{prettify(v)}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
<button
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
</Label>
</div>
);
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
{editingProvider &&
(() => {
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
return null;
return (
<div key={provider.providerId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
@@ -507,7 +510,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder="Enter API key"
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
provider,
fromName,
fromEmail,
...(provider === 'resend'
? { apiKey }
: { host, port: parseInt(port) || 587, username, password, secure }),
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
});
const handleSave = async () => {
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTestingConnection(false);
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
if (isTesting || !testEmail) return;
setIsTesting(true);
try {
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
...buildConfig(),
to: testEmail,
});
if (result.error) {
toast.error(result.error);
} else {
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Failed to send test email';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTesting(false);
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
type Provider = 'openai' | 'elevenlabs';
@@ -47,7 +55,11 @@ export const TTSSection = () => {
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
setVoicesLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
const res = await client.post<{
voices?: string[];
groups?: { label: string; voices: string[] }[];
error?: string;
}>('/server-settings/tts/voices', {
provider: p,
url: u,
apiKey: key || undefined,
@@ -167,7 +179,9 @@ export const TTSSection = () => {
)}
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
API Key {provider === 'openai' ? '(optional)' : ''}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -197,7 +211,9 @@ export const TTSSection = () => {
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{voices.length > 0 ? (
@@ -209,16 +225,21 @@ export const TTSSection = () => {
{voiceGroups.length > 0
? voiceGroups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
<SelectItem key={v} value={v}>
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
to={to}
className={({ isActive }) =>
`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'
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'
}`
}
>
{({ isActive }) => (
<>
<section.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'}`} />
<section.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">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
@@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
</NavLink>
);
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
export const SettingsSidebar = ({
basePath,
icon: Icon,
label,
sections,
groups,
hideHeader,
}: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const query = search.toLowerCase();
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
)}
<div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
<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">
{groups
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
);
})
: sections.filter(matchesSearch).map((s) => (
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
: sections
.filter(matchesSearch)
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
</div>
</div>
);
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
groups?: SettingsSectionGroup[];
};
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
export const createSettingsPanelComponents = ({
basePath,
sidebarIcon,
sidebarLabel,
sections = [],
groups,
}: CreateSettingsPanelParams) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => (
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
<SettingsSidebar
basePath={basePath}
icon={sidebarIcon}
label={sidebarLabel}
sections={allSections}
groups={groups}
/>
);
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections };
@@ -1,5 +1,6 @@
export * from './AppStore';
export * from './Plugins';
export * from './PluginScreen';
export * from './Layout';
export * from './Home';
export * from './PasskeyGate';