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
+29
View File
@@ -480,6 +480,35 @@ export const manifest = {
} as const; } as const;
``` ```
### THE RULE: every plugin route renders a Workspace with at least one panel
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
```
web/panels.ts exports appRegistryMetas — at least one panel
web/layout.ts exports defaultLayout — how they are arranged
```
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
name and with the reason:
```
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. A plugin that could would be free to render a bare
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
is nowhere to put a screen.
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.
### Everything the tree can say, the tree says ### Everything the tree can say, the tree says
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
+23
View File
@@ -0,0 +1,23 @@
import { useParams } from 'react-router';
// The second panel, reading the URL rather than being told by its sibling.
//
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
// independently; nothing is passed between them, so they cannot disagree.
export const ExampleDetail = () => {
const { section } = useParams();
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? '(none)'}</code>
</p>
<p className="mt-3 text-xs text-duck-dark/40">
Try <code>/example/anything</code> this panel reads it from <code>useParams</code>, with no state passed from
the panel beside it.
</p>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
//
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
export const ExampleOverview = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ['example', 'ping'],
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
});
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
<p className="mt-1 text-sm text-duck-dark/60">
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
{isLoading ? <span className="text-duck-dark/40"></span> : <span>{JSON.stringify(data)}</span>}
</div>
</div>
);
};
-36
View File
@@ -1,36 +0,0 @@
import { Routes, Route, Link } from 'react-router';
// The plugin's own router, mounted by the shell at `<prefix>/*` — so everything below this point is the
// plugin's, and react-router nests it natively. The shell knows the prefix; this file does not need to.
const Home = () => (
<div className="p-8">
<h1 className="text-xl font-semibold text-duck-dark">Example plugin</h1>
<p className="mt-2 text-sm text-duck-dark/60">
Rendered from <code>plugins/example/web/Router.tsx</code>, compiled into the shell's bundle by the generated{' '}
<code>Plugins.gen.tsx</code>.
</p>
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example/deeper">
A nested route
</Link>
</div>
);
const Deeper = () => (
<div className="p-8">
<h1 className="text-xl font-semibold text-duck-dark">Nested</h1>
<p className="mt-2 text-sm text-duck-dark/60">Proof the wildcard mount hands the whole subtree to the plugin.</p>
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example">
back
</Link>
</div>
);
export default function ExampleRouter() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/deeper" element={<Deeper />} />
</Routes>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
//
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
// mismatch falls back rather than rendering another plugin's panel inside this screen.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'example-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
],
};
+12 -6
View File
@@ -1,10 +1,16 @@
import { Puzzle } from 'lucide-react'; import { Puzzle, ListTree } from 'lucide-react';
import type { AppRegistryMeta } from 'officerdev'; import type { AppRegistryMeta } from 'officerdev';
import ExampleRouter from './Router'; import { ExampleOverview } from './ExampleOverview';
import { ExampleDetail } from './ExampleDetail';
// Panels this plugin contributes to the workspace registry. The shell passes them to `seedAppRegistry` // The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
// from the generated module — it never imports this file directly, because officerdev is a dependency of //
// the shell and importing upward would invert that. // A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
// a rule someone has to remember.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [ export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'example-panel', name: 'Example', icon: Puzzle, component: ExampleRouter, availableOnPanel: true }, { key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
]; ];
+11 -3
View File
@@ -72,9 +72,17 @@ export function App() {
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from {/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
hands the whole subtree to the plugin's own router, which react-router nests natively. */} hands the whole subtree to the plugin's own router, which react-router nests natively. */}
{installedPlugins.map((plugin) => ( {installedPlugins.flatMap((plugin) => [
<Route key={plugin.appName} path={`${plugin.route}/*`} element={<plugin.Router />} /> <Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
))} // The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route
key={`${plugin.appName}-section`}
path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])}
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} /> <Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -28,7 +28,7 @@ export function ResetPassword() {
</Card> </Card>
)} )}
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}> <Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Reset Password</div> <div className="text-duck-dark text-2xl font-bold">Reset Password</div>
<div className="text-duck-dark/60">Enter your new password</div> <div className="text-duck-dark/60">Enter your new password</div>
@@ -105,4 +105,3 @@ const validateForm = (state: Partial<LoginFormState>) => {
if (!email || !password) return false; if (!email || !password) return false;
return true; return true;
}; };
@@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
<section className="relative h-dvh snap-start overflow-hidden"> <section className="relative h-dvh snap-start overflow-hidden">
<Background /> <Background />
<div className="absolute inset-0 z-20"> <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"> <div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
{children}
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -1,5 +1,5 @@
import { DuckAvatar } from "./DuckAvatar"; import { DuckAvatar } from './DuckAvatar';
import { PixelGrid } from "@/components/PixelGrid"; import { PixelGrid } from '@/components/PixelGrid';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
const landscapebg = '/landscape1.webp'; const landscapebg = '/landscape1.webp';
@@ -19,4 +19,4 @@ export function Background() {
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />} {!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
</div> </div>
); );
}; }
@@ -14,4 +14,3 @@ export const SignoutScreen = () => {
return null; return null;
}; };
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
// Poll the registry (harness task files + announced detached jobs). // Poll the registry (harness task files + announced detached jobs).
useEffect(() => { useEffect(() => {
let alive = true; 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(); tick();
const iv = setInterval(tick, POLL_MS); 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, // 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. // 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 row = selectedId
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`; ? (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). // Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
useEffect(() => { useEffect(() => {
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
try { try {
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine }; const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
if (d.kind === 'progress' && d.progress) setProgress(d.progress); 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!]); else if (d.kind === 'line' && typeof d.text === 'string')
} catch { /* ignore */ } setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch {
/* ignore */
}
}; };
return () => es.close(); return () => es.close();
}, [query, token]); }, [query, token]);
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]); useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [lines]);
const rowCls = (active: boolean) => 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'}`; `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 <ActivityIcon size={16} className="text-primary" /> Activity
</div> </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.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => ( {reg.tasks.map((t) => (
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}> <Link
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} /> 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> <span className="truncate font-mono text-xs">{t.id}</span>
</Link> </Link>
))} ))}
{reg.detached.length > 0 && ( {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) => ( {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" /> <FileText size={13} className="shrink-0" />
<span className="truncate">{d.id}</span> <span className="truncate">{d.id}</span>
</Link> </Link>
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
{[progress.cap, progress.phase].filter(Boolean).join(' · ')} {[progress.cap, progress.phase].filter(Boolean).join(' · ')}
{progress.status ? ` (${progress.status})` : ''} {progress.status ? ` (${progress.status})` : ''}
</span> </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> </div>
{typeof progress.pct === 'number' && ( {typeof progress.pct === 'number' && (
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted"> <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>
)} )}
</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 ? ( {lines.length === 0 ? (
<span className="text-muted-foreground"> <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.{' '} no run called <span className="font-mono">{selectedId}</span> is in the registry it finished, or
<Link to="/activity" className="underline">Back to the list</Link> it never started.{' '}
<Link to="/activity" className="underline">
Back to the list
</Link>
</> </>
) : 'loading…'} ) : (
'loading…'
)}
</span> </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>
</> </>
) : ( ) : (
<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> </main>
</div> </div>
@@ -87,7 +87,13 @@ export const TabPreview = () => {
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
{/* URL bar */} {/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2"> <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" />} {isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button> </Button>
<form <form
@@ -104,7 +110,13 @@ export const TabPreview = () => {
placeholder="Navigate to URL..." placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500" 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" /> <Send className="h-3.5 w-3.5" />
</Button> </Button>
</form> </form>
@@ -143,7 +155,13 @@ export const TabPreview = () => {
placeholder="Evaluate JavaScript..." placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono" 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'} {isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button> </Button>
</form> </form>
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
type Contact = { address: string; name: string }; type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment. // 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 client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]); const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
return; return;
} }
const t = setTimeout(() => { 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); }, 180);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [seg]); }, [seg]);
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
inlineMap.current.clear(); inlineMap.current.clear();
nextImgId.current = 0; nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content). // 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(); refreshEmpty();
} }
if (!draft) seeded.current = null; if (!draft) seeded.current = null;
}, [draft]); }, [draft]);
// Clipboard images often come nameless — give them a sensible filename. // 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. // Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => { 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; 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" className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/> />
<div className="relative flex-1 overflow-hidden"> <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 <div
ref={editorRef} ref={editorRef}
contentEditable contentEditable
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</button> </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 Cancel
</button> </button>
<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 // 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 // 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.) // 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 addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`; const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim(); const original = (m.text || m.snippet || '').trim();
const quoted = original 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 }; return { to: addr, subject, body: quoted };
}; };
@@ -63,8 +63,13 @@ type MessagePanelProps = {
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => { const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) { if (!open) {
return ( 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"> <button
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span> 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> <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" />} {!!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> <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> </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> </div>
); );
}; };
@@ -209,7 +218,11 @@ export const EmailReader = () => {
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0"> <DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle> <DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && ( {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"> <div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader /> <FileViewerHeader />
</div> </div>
@@ -1,8 +1,16 @@
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
import { useParams, Link } from 'react-router'; import { useParams, Link } from 'react-router';
import { import {
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square, ArrowLeft,
ChevronRight, Wrench, CheckCircle2,
AlertCircle,
Loader2,
StopCircle,
AlertTriangle,
Clock,
Square,
ChevronRight,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
@@ -53,21 +61,58 @@ type JobData = {
// Output entries for the right panel // Output entries for the right panel
type OutputEntry = type OutputEntry =
| { id: string; type: 'text'; text: string } | { 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 = type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] } | { 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:complete'; stepIndex: number; cost?: Cost }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string } | { 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:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost } | { 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: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: 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: '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: 'pipeline:complete'; totalCost: Cost }
| { jobId: string; type: 'error'; message: string } | { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' } | { jobId: string; type: 'stopped' }
@@ -86,7 +131,7 @@ const formatElapsed = (seconds: number) => {
const formatCost = (cost: number) => `$${cost.toFixed(4)}`; 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 */ /** Build a unique key for grouping output by step/iteration */
const outputKey = (stepIndex: number, iterationLabel?: string) => 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" /> <Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span> <span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
{entry.output !== undefined && ( {entry.output !== undefined && (
<StatusIcon <StatusIcon status={entry.isError ? 'error' : 'complete'} className="h-3 w-3 shrink-0 ml-auto" />
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> </button>
{expanded && ( {expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10"> <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 && ( {entry.output !== undefined && (
<div> <div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</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} {entry.output}
</pre> </pre>
</div> </div>
@@ -194,9 +238,18 @@ const DEFAULT_LAYOUT: LayoutNode = {
const StepsPanel = () => { const StepsPanel = () => {
const ctx = useJobPanel(); const ctx = useJobPanel();
const { const {
displaySteps, isLive, isRunning, completedSteps, activeStepIndex, displaySteps,
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel, isLive,
skippedItems, outputMap, isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
} = ctx; } = ctx;
return ( return (
@@ -261,16 +314,16 @@ const StepsPanel = () => {
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" /> <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> <span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
{it.cost && ( {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" />} {itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
</button> </button>
); );
})} })}
{skippedItems.length > 0 && ( {skippedItems.length > 0 && (
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40"> <div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
{skippedItems.length} skipped
</div>
)} )}
</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"> <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> <h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
{selectedKey && ( {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>
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs"> <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> </div>
); );
} }
return ( return <ToolCallEntry key={entry.id} entry={entry} />;
<ToolCallEntry key={entry.id} entry={entry} />
);
})} })}
{selectedStreaming && ( {selectedStreaming && (
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed"> <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 timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => { 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) => { const addCost = useCallback((cost: Cost) => {
@@ -390,9 +446,10 @@ export const PipelineJobDetail = () => {
const arr = prev.get(key); const arr = prev.get(key);
if (!arr) return prev; if (!arr) return prev;
const next = new Map(prev); const next = new Map(prev);
next.set(key, arr.map((e) => next.set(
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e, key,
)); arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
);
return next; return next;
}); });
}, []); }, []);
@@ -461,16 +518,25 @@ export const PipelineJobDetail = () => {
}; };
}, [id, job?.status]); }, [id, job?.status]);
const handleEvent = useCallback((msg: ServerMessage) => { const handleEvent = useCallback(
(msg: ServerMessage) => {
switch (msg.type) { switch (msg.type) {
case 'job:state': case 'job:state':
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') { if (
msg.status === 'completed' ||
msg.status === 'failed' ||
msg.status === 'stopped' ||
msg.status === 'interrupted'
) {
setLiveStatus('done'); setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost); if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer(); stopTimer();
if (id) { if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {}); client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
} }
} }
if (msg.progress) { if (msg.progress) {
@@ -487,9 +553,7 @@ export const PipelineJobDetail = () => {
case 'step:start': { case 'step:start': {
setParallelStep(null); setParallelStep(null);
setActiveStepIndex(msg.stepIndex); setActiveStepIndex(msg.stepIndex);
const key = msg.iteration const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex);
? outputKey(msg.stepIndex, msg.iteration.label)
: outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key); if (autoFollowRef.current) setSelectedKey(key);
break; break;
} }
@@ -526,9 +590,7 @@ export const PipelineJobDetail = () => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
iterations: prev.iterations.map((it) => iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
it.label === msg.label ? { ...it, status: 'running' } : it,
),
}; };
}); });
break; break;
@@ -575,7 +637,11 @@ export const PipelineJobDetail = () => {
appendOutput(key, { id: randomId(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
} }
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
break; break;
} }
@@ -604,14 +670,20 @@ export const PipelineJobDetail = () => {
setLiveStatus('done'); setLiveStatus('done');
setCompletedSteps((prev) => { setCompletedSteps((prev) => {
const next = new Set(prev); const next = new Set(prev);
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; }); setSteps((s) => {
s.forEach((_, i) => next.add(i));
return s;
});
return next; return next;
}); });
setActiveStepIndex(-1); setActiveStepIndex(-1);
setParallelStep(null); setParallelStep(null);
stopTimer(); stopTimer();
if (id) { if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {}); client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
} }
break; break;
@@ -626,16 +698,25 @@ export const PipelineJobDetail = () => {
stopTimer(); stopTimer();
break; break;
} }
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]); },
[id, stopTimer, addCost, appendOutput, updateToolOutput],
);
const flushStreamBuffer = useCallback((key: string) => { const flushStreamBuffer = useCallback(
(key: string) => {
const text = streamBuffers.current.get(key); const text = streamBuffers.current.get(key);
if (text) { if (text) {
appendOutput(key, { id: randomId(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
} }
}, [appendOutput]); },
[appendOutput],
);
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
@@ -648,10 +729,13 @@ export const PipelineJobDetail = () => {
setSelectedKey(key); setSelectedKey(key);
}, []); }, []);
const panelComponents: PanelComponents = useMemo(() => ({ const panelComponents: PanelComponents = useMemo(
() => ({
steps: StepsPanel, steps: StepsPanel,
output: OutputPanel, output: OutputPanel,
}), []); }),
[],
);
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending'; const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
const isRunning = displayStatus === 'running'; const isRunning = displayStatus === 'running';
@@ -659,7 +743,10 @@ export const PipelineJobDetail = () => {
const jobDone = !isRunning && !isLive; const jobDone = !isRunning && !isLive;
const progressStepIndex = job?.progress?.currentStepIndex ?? -1; const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? { const displayParallel: ParallelStep | null =
parallelStep ??
(jobDone && job?.progress?.parallel
? {
stepIndex: progressStepIndex, stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName, taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency, concurrency: job.progress.parallel.concurrency,
@@ -667,29 +754,54 @@ export const PipelineJobDetail = () => {
label: it.label, label: it.label,
status: it.status as IterationStatus['status'], status: it.status as IterationStatus['status'],
})), })),
} : null); }
: null);
const panelCtx = useMemo<JobPanelContext>(() => ({ const panelCtx = useMemo<JobPanelContext>(
displaySteps, isLive, isRunning, completedSteps, activeStepIndex, () => ({
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput, displaySteps,
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef, isLive,
}), [ isRunning,
displaySteps, isLive, isRunning, completedSteps, activeStepIndex, completedSteps,
progressStepIndex, job?.status, selectedKey, selectOutput, activeStepIndex,
displayParallel, skippedItems, outputMap, streamingMap, 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) { if (isLoading) {
return ( return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
);
} }
if (!job) { if (!job) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm"> <div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span> <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> </div>
); );
} }
@@ -699,7 +811,9 @@ export const PipelineJobDetail = () => {
? elapsed ? elapsed
: job.startedAt && job.completedAt : job.startedAt && job.completedAt
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000) ? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
: elapsed > 0 ? elapsed : null; : elapsed > 0
? elapsed
: null;
return ( return (
<div className="flex h-full flex-col p-3 md:p-6 gap-4"> <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"> <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"> <div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" /> <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> </div>
</Card> </Card>
)} )}
@@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
</div> </div>
); );
}; };
@@ -1,4 +1,4 @@
import { PixelGrid } from "@/components/PixelGrid"; import { PixelGrid } from '@/components/PixelGrid';
const landscapebg = '/landscape1.webp'; const landscapebg = '/landscape1.webp';
export function Background() { export function Background() {
@@ -14,4 +14,4 @@ export function Background() {
<PixelGrid /> <PixelGrid />
</div> </div>
); );
}; }
@@ -13,7 +13,14 @@ type BugReportDialogProps = {
onSubmit: (description: string) => void; 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 [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]); 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"> <div className="grid gap-5">
{status && ( {status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"> <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"> <span className="text-sm text-duck-dark dark:text-foreground">
{status.configured ? 'API token configured' : 'Not configured'} {status.configured ? 'API token configured' : 'Not configured'}
</span> </span>
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
/> />
<span className="text-xs text-duck-dark/40 dark:text-foreground/40"> <span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Get your token at{' '} 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 console.apify.com/account/integrations
</a> </a>
</span> </span>
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
<p className="text-xs text-duck-dark/50 dark:text-foreground/50"> <p className="text-xs text-duck-dark/50 dark:text-foreground/50">
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached {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> </p>
</div> </div>
</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"> <ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<li> <li>
Open{' '} 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 in Chrome
</li> </li>
<li> <li>
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
variant="outline"
size="sm"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" /> <RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Regenerate Regenerate
</Button> </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"> <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-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"> <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 Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
the tab is connected. Then go to{' '} means the tab is connected. Then go to{' '}
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5"> <a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
/browser <ExternalLink className="h-3 w-3" /> /browser <ExternalLink className="h-3 w-3" />
</a>{' '} </a>{' '}
@@ -189,10 +188,11 @@ type CredentialRowProps = {
const CredentialRow = ({ label, value, masked, copied, onCopy }: 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"> <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> <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"> <code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value} <button
</code> onClick={onCopy}
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"> 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" />} {copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button> </button>
</div> </div>
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
const progress = accountJob?.steps[accountJob.currentStep]?.progress; const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return ( return (
<div <div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
key={account.id}
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} /> <ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1"> <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> <strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1"> <p className="mt-1">
Go to the{' '} 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 New Project
</a>{' '} </a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>. 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> <strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} 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 API Library
</a> </a>
. Search for and enable each of these: . Search for and enable each of these:
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li> <li>
<li><strong>Google Calendar API</strong></li> <strong>Gmail API</strong>
</li>
<li>
<strong>Google Calendar API</strong>
</li>
</ul> </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>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong> <strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} 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 OAuth Branding
</a> </a>
. .
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <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>
<li>Set <strong>User support email</strong> to your admin email</li> Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
<li>Add your admin email under <strong>Developer contact information</strong></li> </li>
<li>Click <strong>Save</strong></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> </ul>
</li> </li>
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong> <strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} 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 OAuth Audience
</a> </a>
. .
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
If your team uses Google Workspace, select <strong>Internal</strong> no verification needed If your team uses Google Workspace, select <strong>Internal</strong> no verification needed
</li> </li>
<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> </li>
</ul> </ul>
</li> </li>
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong> <strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} 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 Data Access
</a> </a>
, then click <strong>Add or remove scopes</strong>. Search for and add: , 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"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => ( {SCOPES.map((s) => (
<li key={s.scope}> <li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded"> <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.scope} {s.description}
</code>{' '}
— {s.description}
</li> </li>
))} ))}
</ul> </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"> <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{' '} Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
<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. is classified as <strong>sensitive</strong> and{' '}
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification. <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> </p>
</li> </li>
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong> <strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} 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 Clients
</a> </a>
, then click <strong>Create OAuth client</strong>. , then click <strong>Create OAuth client</strong>.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <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>Name: anything (e.g. &quot;Officer&quot;)</li>
<li> <li>
Authorized redirect URIs: add{' '} Authorized redirect URIs: add{' '}
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
{redirectUri} {redirectUri}
</code> </code>
</li> </li>
<li>Click <strong>Create</strong></li> <li>
Click <strong>Create</strong>
</li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong> <strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1"> <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> </p>
</li> </li>
</ol> </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="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'}`} /> <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'}`}> <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> </span>
</div> </div>
); );
@@ -87,19 +87,25 @@ export const AIModels = () => {
<div className="grid gap-5"> <div className="grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span> <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')} {renderModelSelect(chatModel, setChatModel, 'System default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span> <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')} {renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span> <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')} {renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label> </Label>
@@ -1,7 +1,15 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react'; import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label'; 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 { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const audio = new Audio(url); const audio = new Audio(url);
audioRef.current = audio; audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); }; audio.onended = () => {
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); }; audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
audio.onerror = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
await audio.play(); await audio.play();
setListening('playing'); setListening('playing');
} catch { } 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" 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" 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> </button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
<SelectValue placeholder="Server default" /> <SelectValue placeholder="Server default" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <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.length > 0
? groups.map((g) => ( ? groups.map((g) => (
<SelectGroup key={g.label}> <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) => ( {g.voices.map((v) => (
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem> <SelectItem key={v} value={v}>
{prettify(v)}
</SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
)) ))
: voices.map((v) => ( : voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem> <SelectItem key={v} value={v}>
)) {v}
} </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<button <button
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
)} )}
</button> </button>
</div> </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> </Label>
</div> </div>
); );
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1" className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'} placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
value={keyInputs[provider.providerId] ?? ''} 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) => { onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId); if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null); if (ev.key === 'Escape') setEditingProvider(null);
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
{editingProvider && {editingProvider &&
(() => { (() => {
const provider = CHAT_PROVIDERS.find((p) => p.key === 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 ( return (
<div key={provider.providerId} className="flex items-center gap-2"> <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]"> <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" className="h-7 text-xs flex-1"
placeholder="Enter API key" placeholder="Enter API key"
value={keyInputs[provider.providerId] ?? ''} 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) => { onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId); if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null); if (ev.key === 'Escape') setEditingProvider(null);
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
provider, provider,
fromName, fromName,
fromEmail, fromEmail,
...(provider === 'resend' ...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
? { apiKey }
: { host, port: parseInt(port) || 587, username, password, secure }),
}); });
const handleSave = async () => { const handleSave = async () => {
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
let msg = 'Connection failed'; 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); toast.error(msg);
} finally { } finally {
setIsTestingConnection(false); setIsTestingConnection(false);
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
if (isTesting || !testEmail) return; if (isTesting || !testEmail) return;
setIsTesting(true); setIsTesting(true);
try { 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) { if (result.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
let msg = 'Failed to send test email'; 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); toast.error(msg);
} finally { } finally {
setIsTesting(false); setIsTesting(false);
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { useClient } from 'hooks/useClient';
type Provider = 'openai' | 'elevenlabs'; type Provider = 'openai' | 'elevenlabs';
@@ -47,7 +55,11 @@ export const TTSSection = () => {
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => { const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
setVoicesLoading(true); setVoicesLoading(true);
try { 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, provider: p,
url: u, url: u,
apiKey: key || undefined, apiKey: key || undefined,
@@ -167,7 +179,9 @@ export const TTSSection = () => {
)} )}
<Label className="grid gap-2"> <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 <Input
type="password" type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" 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" 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" 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> </button>
</div> </div>
{voices.length > 0 ? ( {voices.length > 0 ? (
@@ -209,16 +225,21 @@ export const TTSSection = () => {
{voiceGroups.length > 0 {voiceGroups.length > 0
? voiceGroups.map((g) => ( ? voiceGroups.map((g) => (
<SelectGroup key={g.label}> <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) => ( {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> </SelectGroup>
)) ))
: voices.map((v) => ( : voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem> <SelectItem key={v} value={v}>
)) {v}
} </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
to={to} to={to}
className={({ isActive }) => className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${ `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 }) => ( {({ 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="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div> <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> <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> </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 [search, setSearch] = useState('');
const query = search.toLowerCase(); const query = search.toLowerCase();
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div> </div>
)} )}
<div className="px-3 pb-2"> <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>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1"> <div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{groups {groups
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div> </div>
); );
}) })
: sections.filter(matchesSearch).map((s) => ( : sections
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} /> .filter(matchesSearch)
))} .map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
</div> </div>
</div> </div>
); );
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
groups?: SettingsSectionGroup[]; 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 allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => ( 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} />; const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections }; return { Sidebar, Content, allSections };
@@ -1,5 +1,6 @@
export * from './AppStore'; export * from './AppStore';
export * from './Plugins'; export * from './Plugins';
export * from './PluginScreen';
export * from './Layout'; export * from './Layout';
export * from './Home'; export * from './Home';
export * from './PasskeyGate'; export * from './PasskeyGate';
+16 -4
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Officer Dev (Alpha)</title> <title>Officer Dev (Alpha)</title>
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." /> <meta
name="description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<!-- OpenGraph. Crawlers fetch these standalone, so they need absolute URLs. __PUBLIC_URL__ is <!-- OpenGraph. Crawlers fetch these standalone, so they need absolute URLs. __PUBLIC_URL__ is
substituted from .env by scripts/gen-index.ts into index.gen.html, which is what the server substituted from .env by scripts/gen-index.ts into index.gen.html, which is what the server
@@ -12,7 +15,10 @@
<!-- OpenGraph --> <!-- OpenGraph -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Officer Dev (Alpha)" /> <meta property="og:title" content="Officer Dev (Alpha)" />
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." /> <meta
property="og:description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<meta property="og:image" content="__PUBLIC_URL__/og-image-v3.jpg" /> <meta property="og:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
<meta property="og:image:secure_url" content="__PUBLIC_URL__/og-image-v3.jpg" /> <meta property="og:image:secure_url" content="__PUBLIC_URL__/og-image-v3.jpg" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
@@ -24,7 +30,10 @@
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Officer Dev (Alpha)" /> <meta name="twitter:title" content="Officer Dev (Alpha)" />
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." /> <meta
name="twitter:description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<meta name="twitter:image" content="__PUBLIC_URL__/og-image-v3.jpg" /> <meta name="twitter:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
<!-- Favicons & App Icons. These must stay absolute: Bun's HTML bundler treats a root-relative <!-- Favicons & App Icons. These must stay absolute: Bun's HTML bundler treats a root-relative
@@ -38,7 +47,10 @@
<meta name="theme-color" content="#1F2620" /> <meta name="theme-color" content="#1F2620" />
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init(); eruda._entryBtn.hide();</script> <script>
eruda.init();
eruda._entryBtn.hide();
</script>
<script type="module" src="./frontend.tsx" async></script> <script type="module" src="./frontend.tsx" async></script>
</head> </head>
<body> <body>
+2 -2
View File
@@ -1,2 +1,2 @@
@import "./globals.css"; @import './globals.css';
@import "./prose.css"; @import './prose.css';
+26 -2
View File
@@ -207,8 +207,21 @@ const serveBuilt = async (req: Request): Promise<Response> => {
// An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file // An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file
// and must return index.html, which is what makes deep links work at all. // and must return index.html, which is what makes deep links work at all.
// A request that LOOKS like an asset and is not one must 404, never fall through to the shell. Serving
// HTML with a JS content-type produces "Unexpected token '<'" and a blank page — a failure mode that
// reads as a broken build rather than a missing file.
if (/\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) {
const file = Bun.file(join(BUILD_DIR, path.slice(1)));
if (!(await file.exists())) return new Response('Not found', { status: 404 });
return new Response(file, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
}
const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1))); const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1)));
if (path !== '/' && (await asset.exists())) return new Response(asset); if (path !== '/' && (await asset.exists())) {
// Content-hashed, so the name changes whenever the bytes do and this can be cached hard. The shell
// below is the opposite case and must not be.
return new Response(asset, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
}
const shell = Bun.file(join(BUILD_DIR, SHELL_FILE)); const shell = Bun.file(join(BUILD_DIR, SHELL_FILE));
if (!(await shell.exists())) { if (!(await shell.exists())) {
@@ -219,7 +232,18 @@ const serveBuilt = async (req: Request): Promise<Response> => {
headers: { 'content-type': 'text/plain' }, headers: { 'content-type': 'text/plain' },
}); });
} }
return new Response(shell, { headers: { 'content-type': 'text/html' } }); // NEVER cache the shell.
//
// It names the hashed chunks, so a cached shell is a browser pinned to a build that no longer exists on
// disk — every asset it asks for 404s, or worse it renders an old app against a current API. Served with
// no cache headers at all until now, which leaves it to the browser's heuristics: a plugin could be
// installed, the bundle rebuilt, and the tab keep running the previous one with no way to tell.
return new Response(shell, {
headers: {
'content-type': 'text/html',
'cache-control': 'no-cache, no-store, must-revalidate',
},
});
}; };
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
+15 -2
View File
@@ -31,12 +31,15 @@ plant('full', {
'api/router.ts': 'export const router = {};', 'api/router.ts': 'export const router = {};',
'db/schema.ts': 'export const t = {};', 'db/schema.ts': 'export const t = {};',
'sidecar/index.ts': 'export {};', 'sidecar/index.ts': 'export {};',
'web/Router.tsx': 'export default () => null;',
'web/panels.ts': 'export const appRegistryMetas = [];', 'web/panels.ts': 'export const appRegistryMetas = [];',
'web/layout.ts': 'export const defaultLayout = {};',
}); });
plant('bare', { 'manifest.ts': MANIFEST() }); plant('bare', { 'manifest.ts': MANIFEST() });
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' }); plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' }); plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
// A frontend that is not a workspace. The rule: every plugin route renders a Workspace with at least one
// panel, enforced here rather than by review — a `web/` directory without both files is refused.
plant('halfweb', { 'manifest.ts': MANIFEST(), 'web/panels.ts': 'export const appRegistryMetas = [];' });
plant('nomanifest', { 'api/router.ts': 'export const router = {};' }); plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
plant('_scratch', { 'manifest.ts': MANIFEST() }); plant('_scratch', { 'manifest.ts': MANIFEST() });
@@ -48,6 +51,7 @@ describe('discoverPlugins', () => {
expect(full.schema).toContain('db/schema.ts'); expect(full.schema).toContain('db/schema.ts');
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' }); expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
expect(full.web?.panels).toContain('web/panels.ts'); expect(full.web?.panels).toContain('web/panels.ts');
expect(full.web?.layout).toContain('web/layout.ts');
}); });
it('a manifest alone is a valid plugin — every other part is optional', async () => { it('a manifest alone is a valid plugin — every other part is optional', async () => {
@@ -71,11 +75,20 @@ describe('discoverPlugins', () => {
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable. // ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
it('collects broken plugins instead of throwing', async () => { it('collects broken plugins instead of throwing', async () => {
const { plugins, broken } = await discoverPlugins(root); const { plugins, broken } = await discoverPlugins(root);
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']); expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'halfweb', 'nomanifest']);
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts'); expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
expect(plugins.length).toBeGreaterThan(0); expect(plugins.length).toBeGreaterThan(0);
}); });
// The exclusionary rule, and the reason it is a shape rather than a policy: a plugin cannot ship a
// frontend that is not a workspace, because it never renders the screen.
it('refuses a web/ directory that is not panels AND a layout', async () => {
const { broken } = await discoverPlugins(root);
const halfweb = broken.find((b) => b.appName === 'halfweb')!;
expect(halfweb.error).toContain('web/layout.ts');
expect(halfweb.error).toContain('contribute panels and a layout, not a screen');
});
it('skips underscore and dot directories, which are scratch space', async () => { it('skips underscore and dot directories, which are scratch space', async () => {
const { plugins, broken } = await discoverPlugins(root); const { plugins, broken } = await discoverPlugins(root);
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch'); expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
+29 -8
View File
@@ -19,8 +19,8 @@ import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './
// api/router.ts a backend router // api/router.ts a backend router
// db/schema.ts tables // db/schema.ts tables
// sidecar/index.ts a process (`.mjs` instead means node — see below) // sidecar/index.ts a process (`.mjs` instead means node — see below)
// web/Router.tsx a frontend // web/panels.ts panel apps — REQUIRED with web/
// web/panels.ts panel apps // web/layout.ts how they are arranged — REQUIRED with web/
// //
// Nothing here reads the database. This answers "what is on disk", which is a different question from // Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store // "what is installed" — the install table answers that, and the two disagreeing is a state the app store
@@ -45,11 +45,29 @@ function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
return null; return null;
} }
function findWeb(dir: string): DiscoveredPlugin['web'] { /**
const router = join(dir, 'web', 'Router.tsx'); * A plugin's frontend, or a reason it is refused.
if (!existsSync(router)) return null; *
const panels = join(dir, 'web', 'panels.ts'); * `web/` present means BOTH `panels.ts` and `layout.ts` must be. That is how "every route renders a
return { router, panels: existsSync(panels) ? panels : null }; * workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
* workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
*/
function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web']; error?: string } {
const webDir = join(dir, 'web');
if (!existsSync(webDir)) return { web: null };
const panels = join(webDir, 'panels.ts');
const layout = join(webDir, 'layout.ts');
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
if (missing.length) {
return {
web: null,
error:
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
};
}
return { web: { panels, layout } };
} }
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null); const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
@@ -75,6 +93,9 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
const problems = manifestProblems(appName, manifest); const problems = manifestProblems(appName, manifest);
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`); if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
const web = findWeb(dir, appName);
if (web.error) throw new Error(web.error);
return { return {
appName, appName,
dir, dir,
@@ -82,7 +103,7 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
api: fileOrNull(join(dir, 'api', 'router.ts')), api: fileOrNull(join(dir, 'api', 'router.ts')),
schema: fileOrNull(join(dir, 'db', 'schema.ts')), schema: fileOrNull(join(dir, 'db', 'schema.ts')),
sidecar: findSidecar(dir), sidecar: findSidecar(dir),
web: findWeb(dir), web: web.web,
}; };
} }
+17 -13
View File
@@ -65,32 +65,25 @@ export function generatePluginsModule(states: PluginState[]): string {
withWeb.forEach((state, i) => { withWeb.forEach((state, i) => {
const { plugin } = state; const { plugin } = state;
const alias = `Plugin${i}`; const alias = `Plugin${i}`;
imports.push(`import ${alias}Router from '${specifier(plugin.web!.router)}';`);
let panels = '[]';
if (plugin.web!.panels) {
imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`); imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`);
panels = `${alias}Panels`; imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(plugin.web!.layout)}';`);
}
entries.push( entries.push(
` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', Router: ${alias}Router, panels: ${panels} },`, ` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', panels: ${alias}Panels, layout: ${alias}Layout },`,
); );
}); });
const body = `${HEADER} const body = `${HEADER}
import type { ComponentType } from 'react'; import type { AppRegistryMeta, LayoutNode } from 'officerdev';
import type { AppRegistryMeta } from 'officerdev';
${imports.join('\n')} ${imports.join('\n')}
export type GeneratedPlugin = { export type GeneratedPlugin = {
appName: string; appName: string;
/** Where it mounts, from \`mountPrefix\`\`/offscale\` for ours, \`/p/<publisher>/<name>\` for others. */ /** Where it mounts, from \`mountPrefix\`\`/offscale\` for ours, \`/p/<publisher>/<name>\` for others. */
route: string; route: string;
/** Mounted at \`\${route}/*\`, so the plugin's own router owns everything beneath it. */ /** The panels it contributes. At least one, or discovery refuses the plugin. */
Router: ComponentType;
/** Panel apps it contributes to the registry. */
panels: AppRegistryMeta[]; panels: AppRegistryMeta[];
/** How they are arranged. The shell renders \`WorkspaceView\` around them. */
layout: LayoutNode;
}; };
export const plugins: GeneratedPlugin[] = [ export const plugins: GeneratedPlugin[] = [
@@ -160,6 +153,17 @@ export async function rebuildFrontend(): Promise<BuildResult> {
return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` }; return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` };
} }
// Make the shell's asset URLs ABSOLUTE.
//
// Bun emits `./chunk-….js` for an HTML entrypoint and `publicPath` does not change it. A relative URL
// resolves against the CURRENT path, so it is correct at `/` and wrong at `/example/deeper`, where the
// browser asks for `/example/chunk-….js`. Every deep link more than one segment deep would fail to
// boot. Rewritten here rather than worked around in the server, because the wrong URL is in the file.
const shellPath = join(STAGING_DIR, SHELL_FILE);
const shell = readFileSync(shellPath, 'utf-8');
const absolute = shell.replace(/(src|href)="\.\//g, '$1="/');
if (absolute !== shell) writeFileSync(shellPath, absolute);
if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true }); if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true });
renameSync(STAGING_DIR, BUILD_DIR); renameSync(STAGING_DIR, BUILD_DIR);
+12 -2
View File
@@ -81,8 +81,18 @@ export type DiscoveredPlugin = {
schema: string | null; schema: string | null;
/** `sidecar/index.{ts,mjs}` — a process for PM2. */ /** `sidecar/index.{ts,mjs}` — a process for PM2. */
sidecar: { script: string; runtime: 'bun' | 'node' } | null; sidecar: { script: string; runtime: 'bun' | 'node' } | null;
/** `web/Router.tsx` — a frontend, mounted at `<mountPrefix>/*` by the generated Plugins.tsx. */ /**
web: { router: string; panels: string | null } | null; * A frontend, as PANELS AND A LAYOUT — never a free-form component.
*
* The rule: every plugin route renders a Workspace with at least one panel. It is enforced by shape
* rather than by review — the plugin does not get to render the screen, it contributes panels and says
* how they are arranged, and the shell renders `WorkspaceView` around them. A plugin that wanted to draw
* something else has nowhere to put it.
*
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
* with a partial frontend.
*/
web: { panels: string; layout: string } | null;
}; };
/** /**