diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index 3e6c257b..f31d8713 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -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 `` and `/: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 diff --git a/plugins/example/web/ExampleDetail.tsx b/plugins/example/web/ExampleDetail.tsx new file mode 100644 index 00000000..366b5905 --- /dev/null +++ b/plugins/example/web/ExampleDetail.tsx @@ -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 `` and `/: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 ( +
+

Detail

+

+ Section from the URL: {section ?? '(none)'} +

+

+ Try /example/anything — this panel reads it from useParams, with no state passed from + the panel beside it. +

+
+ ); +}; diff --git a/plugins/example/web/ExampleOverview.tsx b/plugins/example/web/ExampleOverview.tsx new file mode 100644 index 00000000..0975c7ca --- /dev/null +++ b/plugins/example/web/ExampleOverview.tsx @@ -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 ( +
+

Example

+

+ A panel from plugins/example/web/, rendered by the shell's WorkspaceView. +

+
+
GET /api/example/ping
+ {isLoading ? : {JSON.stringify(data)}} +
+
+ ); +}; diff --git a/plugins/example/web/Router.tsx b/plugins/example/web/Router.tsx deleted file mode 100644 index ce388886..00000000 --- a/plugins/example/web/Router.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Routes, Route, Link } from 'react-router'; - -// The plugin's own router, mounted by the shell at `/*` — 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 = () => ( -
-

Example plugin

-

- Rendered from plugins/example/web/Router.tsx, compiled into the shell's bundle by the generated{' '} - Plugins.gen.tsx. -

- - A nested route → - -
-); - -const Deeper = () => ( -
-

Nested

-

Proof the wildcard mount hands the whole subtree to the plugin.

- - ← back - -
-); - -export default function ExampleRouter() { - return ( - - } /> - } /> - - ); -} diff --git a/plugins/example/web/layout.ts b/plugins/example/web/layout.ts new file mode 100644 index 00000000..8cb98846 --- /dev/null +++ b/plugins/example/web/layout.ts @@ -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 }, + ], +}; diff --git a/plugins/example/web/panels.ts b/plugins/example/web/panels.ts index 02ba7915..4746a532 100644 --- a/plugins/example/web/panels.ts +++ b/plugins/example/web/panels.ts @@ -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 }, ]; diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 5238d6bd..438b0d86 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -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) => ( - } /> - ))} + {installedPlugins.flatMap((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. + } + />, + ])} } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Authentication/ForgotPassword/ResetPassword.tsx b/src/apps/officer-web/Screens/Authentication/ForgotPassword/ResetPassword.tsx index 3d5da1cb..c7a08e96 100644 --- a/src/apps/officer-web/Screens/Authentication/ForgotPassword/ResetPassword.tsx +++ b/src/apps/officer-web/Screens/Authentication/ForgotPassword/ResetPassword.tsx @@ -28,7 +28,7 @@ export function ResetPassword() { )} - +
Reset Password
Enter your new password
diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx index 205fa0f1..d9451daa 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx @@ -105,4 +105,3 @@ const validateForm = (state: Partial) => { if (!email || !password) return false; return true; }; - diff --git a/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx b/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx index 96192d93..1e4002d7 100644 --- a/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx +++ b/src/apps/officer-web/Screens/Authentication/Layout/AuthenticationLayout.tsx @@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
-
- {children} -
+
{children}
diff --git a/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx b/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx index 1739122c..7278b8c8 100644 --- a/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx +++ b/src/apps/officer-web/Screens/Authentication/Layout/Background.tsx @@ -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 && } ); -}; +} diff --git a/src/apps/officer-web/Screens/Authentication/Signout.tsx b/src/apps/officer-web/Screens/Authentication/Signout.tsx index 55b09ea5..1a91086a 100644 --- a/src/apps/officer-web/Screens/Authentication/Signout.tsx +++ b/src/apps/officer-web/Screens/Authentication/Signout.tsx @@ -14,4 +14,3 @@ export const SignoutScreen = () => { return null; }; - diff --git a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx index ffa7e95b..ef660cb1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx @@ -27,16 +27,33 @@ export const ActivityScreen = () => { // Poll the registry (harness task files + announced detached jobs). useEffect(() => { let alive = true; - const tick = () => get('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {}); + const tick = () => + get('/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 = () => { Activity -
Background tasks
+
+ Background tasks +
{reg.tasks.length === 0 &&

none running

} {reg.tasks.map((t) => ( - - + + {t.id} ))} {reg.detached.length > 0 && ( -
Detached
+
+ Detached +
)} {reg.detached.map((d) => ( - + {d.id} @@ -103,33 +141,54 @@ export const ActivityScreen = () => { {[progress.cap, progress.phase].filter(Boolean).join(' · ')} {progress.status ? ` (${progress.status})` : ''}
- {progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')} + + {progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')} + {typeof progress.pct === 'number' && (
-
+
)}
)}
-
+
{lines.length === 0 ? ( - {query ? 'waiting for output…' : regLoaded ? ( + {query ? ( + 'waiting for output…' + ) : regLoaded ? ( <> - no run called {selectedId} is in the registry — it finished, or it never started.{' '} - Back to the list + no run called {selectedId} is in the registry — it finished, or + it never started.{' '} + + Back to the list + - ) : 'loading…'} + ) : ( + 'loading…' + )} ) : ( - lines.map((l, i) =>
{l}
) + lines.map((l, i) => ( +
+ {l} +
+ )) )}
) : ( -
Select a task to follow its live output
+
+ Select a task to follow its live output +
)}
diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx index dd2186e1..cd4dec04 100644 --- a/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx @@ -87,7 +87,13 @@ export const TabPreview = () => {
{/* URL bar */}
-
{ placeholder="Navigate to URL..." className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500" /> -
@@ -143,7 +155,13 @@ export const TabPreview = () => { placeholder="Evaluate JavaScript..." className="flex-1 bg-transparent text-sm outline-none font-mono" /> - diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx index 6e5bd903..f4845258 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx @@ -14,7 +14,17 @@ export const useComposer = () => useGlobal('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([]); const [open, setOpen] = useState(false); @@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st return; } const t = setTimeout(() => { - client.get(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {}); + client + .get(`/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, '
') : ''; + if (editorRef.current) + editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '
') : ''; 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" />
- {bodyEmpty &&
Write your message…
} + {bodyEmpty && ( +
Write your message…
+ )}
{ > -
)} - {message.html ? :
{message.text}
} + {message.html ? ( + + ) : ( +
{message.text}
+ )}
); }; @@ -209,7 +218,11 @@ export const EmailReader = () => { {openAttachment?.fileName} {openAttachment && ( - +
diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx index b6fc1ee6..8a6d745a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx @@ -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; output?: string; isError?: boolean }; + | { + id: string; + type: 'tool'; + toolCallId: string; + toolName: string; + toolInput: Record; + 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; 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; + 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) => { {entry.toolName} {entry.output !== undefined && ( - + )} - {entry.output === undefined && ( - - )} - + {entry.output === undefined && } + {expanded && (
@@ -149,7 +191,9 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => { {entry.output !== undefined && (
Output
-
+              
                 {entry.output}
               
@@ -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 = () => { {it.label} {it.cost && ( - {formatCost(it.cost.totalUSD)} + + {formatCost(it.cost.totalUSD)} + )} {itHasOutput && } ); })} {skippedItems.length > 0 && ( -
- {skippedItems.length} skipped -
+
{skippedItems.length} skipped
)}
)} @@ -294,7 +347,9 @@ const OutputPanel = () => {

Output

{selectedKey && ( - {selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`} + + {selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`} + )}
@@ -316,9 +371,7 @@ const OutputPanel = () => {
); } - return ( - - ); + return ; })} {selectedStreaming && (
@@ -365,7 +418,10 @@ export const PipelineJobDetail = () => { const timerRef = useRef | null>(null); const stopTimer = useCallback(() => { - if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } }, []); const addCost = useCallback((cost: Cost) => { @@ -390,9 +446,10 @@ export const PipelineJobDetail = () => { const arr = prev.get(key); if (!arr) return prev; const next = new Map(prev); - next.set(key, arr.map((e) => - e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e, - )); + next.set( + key, + arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)), + ); return next; }); }, []); @@ -461,181 +518,205 @@ export const PipelineJobDetail = () => { }; }, [id, job?.status]); - const handleEvent = useCallback((msg: ServerMessage) => { - switch (msg.type) { - case 'job:state': - if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') { + const handleEvent = useCallback( + (msg: ServerMessage) => { + switch (msg.type) { + case 'job:state': + if ( + msg.status === 'completed' || + msg.status === 'failed' || + msg.status === 'stopped' || + msg.status === 'interrupted' + ) { + setLiveStatus('done'); + if (msg.cost) setTotalCost(msg.cost as Cost); + if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); + stopTimer(); + if (id) { + client + .get(`/pipeline-jobs/${id}`) + .then(setJob) + .catch(() => {}); + } + } + if (msg.progress) { + const p = msg.progress as ProgressData; + if (p?.steps) setSteps(p.steps); + } + break; + + case 'pipeline:init': + setIsLive(true); + setSteps(msg.steps); + break; + + case 'step:start': { + setParallelStep(null); + setActiveStepIndex(msg.stepIndex); + const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex); + if (autoFollowRef.current) setSelectedKey(key); + break; + } + + case 'step:complete': + setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex)); + setParallelStep(null); + setActiveStepIndex(-1); + if (msg.cost) addCost(msg.cost); + // Flush any remaining stream buffer for this step + flushStreamBuffer(outputKey(msg.stepIndex)); + break; + + case 'step:skip': + setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]); + break; + + case 'step:parallel': + setActiveStepIndex(msg.stepIndex); + setParallelStep({ + stepIndex: msg.stepIndex, + taskName: msg.taskName, + concurrency: msg.concurrency, + iterations: msg.iterations.map((label) => ({ label, status: 'pending' })), + }); + // Auto-select first iteration + if (autoFollowRef.current && msg.iterations.length > 0) { + setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0])); + } + break; + + case 'iteration:start': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)), + }; + }); + break; + + case 'iteration:complete': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => + it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it, + ), + }; + }); + if (msg.cost) addCost(msg.cost); + flushStreamBuffer(outputKey(msg.stepIndex, msg.label)); + break; + + case 'iteration:error': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => + it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it, + ), + }; + }); + flushStreamBuffer(outputKey(msg.stepIndex, msg.label)); + break; + + case 'assistant:delta': { + const key = outputKey(msg.stepIndex, msg.iterationLabel); + const buf = streamBuffers.current; + buf.set(key, (buf.get(key) ?? '') + msg.text); + setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!)); + break; + } + + case 'assistant:text': { + const key = outputKey(msg.stepIndex, msg.iterationLabel); + const text = msg.text || streamBuffers.current.get(key) || ''; + if (text) { + appendOutput(key, { id: randomId(), type: 'text', text }); + } + streamBuffers.current.delete(key); + setStreamingMap((prev) => { + const n = new Map(prev); + n.delete(key); + return n; + }); + break; + } + + case 'tool:start': { + const key = outputKey(msg.stepIndex, msg.iterationLabel); + // Flush any streaming text before the tool call + flushStreamBuffer(key); + appendOutput(key, { + id: randomId(), + type: 'tool', + toolCallId: msg.toolCallId, + toolName: msg.toolName, + toolInput: msg.toolInput, + }); + break; + } + + case 'tool:result': { + const key = outputKey(msg.stepIndex, msg.iterationLabel); + updateToolOutput(key, msg.toolCallId, msg.output, msg.isError); + break; + } + + case 'pipeline:complete': + setTotalCost(msg.totalCost); setLiveStatus('done'); - if (msg.cost) setTotalCost(msg.cost as Cost); - if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); + setCompletedSteps((prev) => { + const next = new Set(prev); + setSteps((s) => { + s.forEach((_, i) => next.add(i)); + return s; + }); + return next; + }); + setActiveStepIndex(-1); + setParallelStep(null); stopTimer(); if (id) { - client.get(`/pipeline-jobs/${id}`).then(setJob).catch(() => {}); + client + .get(`/pipeline-jobs/${id}`) + .then(setJob) + .catch(() => {}); } - } - if (msg.progress) { - const p = msg.progress as ProgressData; - if (p?.steps) setSteps(p.steps); - } - break; + break; - case 'pipeline:init': - setIsLive(true); - setSteps(msg.steps); - break; + case 'error': + setHasError(true); + setLiveStatus('done'); + stopTimer(); + break; - case 'step:start': { - setParallelStep(null); - setActiveStepIndex(msg.stepIndex); - const key = msg.iteration - ? outputKey(msg.stepIndex, msg.iteration.label) - : outputKey(msg.stepIndex); - if (autoFollowRef.current) setSelectedKey(key); - break; + case 'stopped': + setLiveStatus('done'); + stopTimer(); + break; } + }, + [id, stopTimer, addCost, appendOutput, updateToolOutput], + ); - case 'step:complete': - setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex)); - setParallelStep(null); - setActiveStepIndex(-1); - if (msg.cost) addCost(msg.cost); - // Flush any remaining stream buffer for this step - flushStreamBuffer(outputKey(msg.stepIndex)); - break; - - case 'step:skip': - setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]); - break; - - case 'step:parallel': - setActiveStepIndex(msg.stepIndex); - setParallelStep({ - stepIndex: msg.stepIndex, - taskName: msg.taskName, - concurrency: msg.concurrency, - iterations: msg.iterations.map((label) => ({ label, status: 'pending' })), - }); - // Auto-select first iteration - if (autoFollowRef.current && msg.iterations.length > 0) { - setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0])); - } - break; - - case 'iteration:start': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'running' } : it, - ), - }; - }); - break; - - case 'iteration:complete': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it, - ), - }; - }); - if (msg.cost) addCost(msg.cost); - flushStreamBuffer(outputKey(msg.stepIndex, msg.label)); - break; - - case 'iteration:error': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it, - ), - }; - }); - flushStreamBuffer(outputKey(msg.stepIndex, msg.label)); - break; - - case 'assistant:delta': { - const key = outputKey(msg.stepIndex, msg.iterationLabel); - const buf = streamBuffers.current; - buf.set(key, (buf.get(key) ?? '') + msg.text); - setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!)); - break; - } - - case 'assistant:text': { - const key = outputKey(msg.stepIndex, msg.iterationLabel); - const text = msg.text || streamBuffers.current.get(key) || ''; - if (text) { - appendOutput(key, { id: randomId(), type: 'text', text }); - } + const flushStreamBuffer = useCallback( + (key: string) => { + const text = streamBuffers.current.get(key); + if (text) { + appendOutput(key, { id: randomId(), type: 'text', text }); streamBuffers.current.delete(key); - setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); - break; - } - - case 'tool:start': { - const key = outputKey(msg.stepIndex, msg.iterationLabel); - // Flush any streaming text before the tool call - flushStreamBuffer(key); - appendOutput(key, { - id: randomId(), - type: 'tool', - toolCallId: msg.toolCallId, - toolName: msg.toolName, - toolInput: msg.toolInput, + setStreamingMap((prev) => { + const n = new Map(prev); + n.delete(key); + return n; }); - break; } - - case 'tool:result': { - const key = outputKey(msg.stepIndex, msg.iterationLabel); - updateToolOutput(key, msg.toolCallId, msg.output, msg.isError); - break; - } - - case 'pipeline:complete': - setTotalCost(msg.totalCost); - setLiveStatus('done'); - setCompletedSteps((prev) => { - const next = new Set(prev); - setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; }); - return next; - }); - setActiveStepIndex(-1); - setParallelStep(null); - stopTimer(); - if (id) { - client.get(`/pipeline-jobs/${id}`).then(setJob).catch(() => {}); - } - break; - - case 'error': - setHasError(true); - setLiveStatus('done'); - stopTimer(); - break; - - case 'stopped': - setLiveStatus('done'); - stopTimer(); - break; - } - }, [id, stopTimer, addCost, appendOutput, updateToolOutput]); - - const flushStreamBuffer = useCallback((key: string) => { - const text = streamBuffers.current.get(key); - if (text) { - appendOutput(key, { id: randomId(), type: 'text', text }); - streamBuffers.current.delete(key); - setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); - } - }, [appendOutput]); + }, + [appendOutput], + ); const handleStop = useCallback(() => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) { @@ -648,10 +729,13 @@ export const PipelineJobDetail = () => { setSelectedKey(key); }, []); - const panelComponents: PanelComponents = useMemo(() => ({ - steps: StepsPanel, - output: OutputPanel, - }), []); + const panelComponents: PanelComponents = useMemo( + () => ({ + steps: StepsPanel, + output: OutputPanel, + }), + [], + ); const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending'; const isRunning = displayStatus === 'running'; @@ -659,37 +743,65 @@ export const PipelineJobDetail = () => { const jobDone = !isRunning && !isLive; const progressStepIndex = job?.progress?.currentStepIndex ?? -1; - const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? { - stepIndex: progressStepIndex, - taskName: job.progress.parallel.taskName, - concurrency: job.progress.parallel.concurrency, - iterations: job.progress.parallel.iterations.map((it) => ({ - label: it.label, - status: it.status as IterationStatus['status'], - })), - } : null); + const displayParallel: ParallelStep | null = + parallelStep ?? + (jobDone && job?.progress?.parallel + ? { + stepIndex: progressStepIndex, + taskName: job.progress.parallel.taskName, + concurrency: job.progress.parallel.concurrency, + iterations: job.progress.parallel.iterations.map((it) => ({ + label: it.label, + status: it.status as IterationStatus['status'], + })), + } + : null); - const panelCtx = useMemo(() => ({ - 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( + () => ({ + 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 ( -
Loading...
- ); + return
Loading...
; } if (!job) { return (
Job not found - Back to jobs + + Back to jobs +
); } @@ -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 (
@@ -758,7 +872,9 @@ export const PipelineJobDetail = () => {
- {job.error ?? 'An error occurred during execution'} + + {job.error ?? 'An error occurred during execution'} +
)} @@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
); }; - diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx index 687ca8e2..c09c1895 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx @@ -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() {
); -}; +} diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx index b9952ced..05ae5b5e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx @@ -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]); diff --git a/src/apps/officer-web/Screens/Dashboard/PluginScreen.tsx b/src/apps/officer-web/Screens/Dashboard/PluginScreen.tsx new file mode 100644 index 00000000..db69dac2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/PluginScreen.tsx @@ -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(`screens/plugin/${appName}`, layout); + const allowed = panels.map((panel) => panel.key); + + return ( +
+ +
+ ); +} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApifyConfig.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApifyConfig.tsx index 33a4e0ad..ad4d3eb7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApifyConfig.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApifyConfig.tsx @@ -51,7 +51,9 @@ export const ApifyConfig = () => {
{status && (
-
+
{status.configured ? 'API token configured' : 'Not configured'} @@ -70,7 +72,12 @@ export const ApifyConfig = () => { /> Get your token at{' '} - + console.apify.com/account/integrations diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx index beff8063..e7a045ee 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx @@ -69,7 +69,9 @@ export const BrowserRelay = () => {

{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached {' — '} - view tabs + + view tabs +

@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
  1. Open{' '} - chrome://extensions{' '} + + chrome://extensions + {' '} in Chrome
  2. @@ -139,12 +143,7 @@ export const BrowserRelay = () => {
- @@ -166,8 +165,8 @@ export const BrowserRelay = () => {

3. Attach a tab

- Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan ON badge means - the tab is connected. Then go to{' '} + Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan ON badge + means the tab is connected. Then go to{' '} /browser {' '} @@ -189,10 +188,11 @@ type CredentialRowProps = { const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (

{label} - - {masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value} - -
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/EmailAccounts.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/EmailAccounts.tsx index 5bdce9da..1023a550 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/EmailAccounts.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/EmailAccounts.tsx @@ -196,10 +196,7 @@ export const EmailAccounts = () => { const progress = accountJob?.steps[accountJob.currentStep]?.progress; return ( -
+
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx index cf590a7b..96452d52 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx @@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { Create a Google Cloud project

Go to the{' '} - + New Project {' '} page. Give it a name (e.g. "Officer") and click Create. @@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { Enable the APIs

Go to{' '} - + API Library . Search for and enable each of these:

    -
  • Gmail API
  • -
  • Google Calendar API
  • +
  • + Gmail API +
  • +
  • + Google Calendar API +
-

Click each one, then click Enable.

+

+ Click each one, then click Enable. +

  • Configure the OAuth consent screen

    Go to{' '} - + OAuth Branding .

      -
    • Set App name to your organization name or "Officer"
    • -
    • Set User support email to your admin email
    • -
    • Add your admin email under Developer contact information
    • -
    • Click Save
    • +
    • + Set App name to your organization name or "Officer" +
    • +
    • + Set User support email to your admin email +
    • +
    • + Add your admin email under Developer contact information +
    • +
    • + Click Save +
  • @@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { Set the audience

    Go to{' '} - + OAuth Audience . @@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { If your team uses Google Workspace, select Internal — no verification needed

  • - Otherwise, select External and add your team's emails under Test users (required while the app is unverified; limit of 100 test users) + Otherwise, select External and add your team's emails under Test users{' '} + (required while the app is unverified; limit of 100 test users)
  • @@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { Add scopes

    In the left sidebar, click{' '} - + Data Access , then click Add or remove scopes. Search for and add: @@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {

      {SCOPES.map((s) => (
    • - - {s.scope} - {' '} - — {s.description} + {s.scope} —{' '} + {s.description}
    • ))}
    -

    Click Update, then Save.

    +

    + Click Update, then Save. +

    - Note: calendar.readonly is classified as sensitive and{' '} - gmail.readonly as restricted 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: calendar.readonly{' '} + is classified as sensitive and{' '} + gmail.readonly as{' '} + restricted 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.

    @@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { Create OAuth credentials

    In the left sidebar, click{' '} - + Clients , then click Create OAuth client.

      -
    • Application type: Web application
    • +
    • + Application type: Web application +
    • Name: anything (e.g. "Officer")
    • Authorized redirect URIs: add{' '} @@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { {redirectUri}
    • -
    • Click Create
    • +
    • + Click Create +
  • Copy the credentials

    - A dialog will show your Client ID and Client Secret. Copy both and paste them into the fields below. + A dialog will show your Client ID and Client Secret. Copy both and paste + them into the fields below.

  • @@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
    - {status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'} + {status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
    ); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx index b6641df4..a1e10c7f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx @@ -87,19 +87,25 @@ export const AIModels = () => {
    diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx index 839d306c..a063d529 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx @@ -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" > - +
    @@ -129,20 +147,27 @@ export const VoicePreference = () => { - Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''} + + Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''} + {groups.length > 0 ? groups.map((g) => ( - {g.label} + + {g.label} + {g.voices.map((v) => ( - {prettify(v)} + + {prettify(v)} + ))} )) : voices.map((v) => ( - {v} - )) - } + + {v} + + ))}
    - Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice. + + Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice. +
    ); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index 52f85b01..d155b151 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -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 (
    {voices.length > 0 ? ( @@ -209,16 +225,21 @@ export const TTSSection = () => { {voiceGroups.length > 0 ? voiceGroups.map((g) => ( - {g.label} + + {g.label} + {g.voices.map((v) => ( - {v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())} + + {v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())} + ))} )) : voices.map((v) => ( - {v} - )) - } + + {v} + + ))} ) : ( diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx index 2db77287..a64577d3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx @@ -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.title}
    {section.description}
    @@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) ); -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,
    )}
    - setSearch(ev.target.value)} className="h-8 text-xs" /> + setSearch(ev.target.value)} + className="h-8 text-xs" + />
    {groups @@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
    ); }) - : sections.filter(matchesSearch).map((s) => ( - - ))} + : sections + .filter(matchesSearch) + .map((s) => )}
    ); @@ -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 = () => ( - + ); const Content: ComponentType = () => ; return { Sidebar, Content, allSections }; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 51a8304a..4226c1fc 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -1,5 +1,6 @@ export * from './AppStore'; export * from './Plugins'; +export * from './PluginScreen'; export * from './Layout'; export * from './Home'; export * from './PasskeyGate'; diff --git a/src/apps/officer-web/index.html b/src/apps/officer-web/index.html index 201c04bf..28550842 100644 --- a/src/apps/officer-web/index.html +++ b/src/apps/officer-web/index.html @@ -4,7 +4,10 @@ Officer Dev (Alpha) - + - + @@ -24,7 +30,10 @@ - +