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