Workbench prototype: pluggable shell, five mocked plugins, plugin SDK - #46
Open
dauglyon wants to merge 175 commits into
Open
Workbench prototype: pluggable shell, five mocked plugins, plugin SDK#46dauglyon wants to merge 175 commits into
dauglyon wants to merge 175 commits into
Conversation
The saved arrangement is a zod-validated Layout: a flat panel map, a split/tab-group tree for the main area, and a pinned-plugin sidebar with per-panel fold state. Operations are the dispatch vocabulary; reduce applies them, describe words them for a live region, and the store undoes by restoring whole snapshots (one push per structural operation in use mode, one per customize session). A layout that fails validation on load is replaced by the default rather than repaired. src/workbench/core and the coming src/workbench/commands are fenced from React and the design system by an ESLint block.
Commands are one kind: a slash name plus typed argument specs registered up front, so the prompt bar completes and validates before the code behind a command has loaded. The registry is framework-free, keyed by slash name, and rejects duplicates. Keybinding defaults live in code and avoid the chords browsers own; the layout stores user overrides on top. The workbench's own commands (close, focus and move by keyboard, fold, pin, undo, customize) dispatch to the store and announce through the caller's live region.
The React layer over the core: a menubar, the sidebar (icon column that is the pinned list, pinned navigators stacked with dividers, fold to header, More pop-out for unpinned plugins), the main area as nested split/tab groups, a prompt bar that resolves slash commands, a status bar and the single live region. Every tab keeps its DOM when hidden. A panel crash or a missing plugin stays inside PanelHost. Keyboard-only close, tab and group focus, and split by direction work from one window listener; DOM focus follows command-driven focus changes and leaves pointer-driven ones alone. The layout persists to localStorage and is restored before first render. Plugin components see two small SDK contexts, usePanel and useHost. The Hello plugin exercises the shell until real plugins land and includes a document that crashes on request. Design system gains ContextMenu, Menubar and Menu.CheckboxItem, additive. /workbench draws its own chrome via staticData.chrome; RouterContext now carries the workbench services so route loaders can reach the store.
dnd-kit supplies the pointer sensor and collision detection; dnd.ts names the drag sources (tabs, block headers) and drop targets (a tab, the strip's end, a group's four edges and centre, the sidebar) and turns a drop into the same move operation the context menus and keyboard commands dispatch, so the pointer path adds speed and no capability. dnd-kit's own announcements are silenced; the drop's result is spoken by the workbench's live region like every other operation. Edge zones render only mid-drag.
The SDK gains the manifest schema (id rules, contractVersion, navigator and document declarations with a document route pattern, command declarations with typed args, promptHandler flag, entry) and the module shape a plugin's entry exports: definePlugin with components, command implementations, an optional prompt handler and a status hook. The host index holds manifests up front and loads a plugin's module the first time a panel renders, a command runs or the assistant is asked. Manifest commands register into the command registry before any code loads, so the bar completes them cold. Routes match TanStack-style patterns; icons come from a host-owned table; the assistant choice lives in a settings store apart from the layout. The Hello plugin becomes an installed plugin with a static manifest and a dynamic import. Announcements now receive the panel record alongside its id, since an opened or closed panel is absent from the layout on one side of the operation.
koros (the assistant): a projects-and-arcs navigator, an arc document at /arc/$slug, a prompt handler that lands free text in the current arc, /new-question, and a live 'answering' status item. data: the Data home with datasets by provenance and a KBase 1.0 bridge keyed by UPA, a dataset document at /data/$ref, and a crash-test fixture. jobs: a job list, a job document at /job/$id, /cancel with a typed id argument, and a 'running' status item. function-junction and genknown: one iframe document each, over mock pages served by the host. App frames live in one fixed layer laid over their panel's placeholder, because a moved iframe reloads; a frame detached and re-attached in the same tick keeps its document. The status bar reads status hooks from modules that have loaded. The catalog is a host-owned navigator over the same index: installed plugins, pin toggles, and the assistant setting. Defaults pin koros, data and jobs and set koros as the assistant.
Typing a slash opens a listbox of matching commands, then of argument values where a command can supply them; Tab or Enter accepts, arrows move, Escape closes the list and then returns focus to the panel. The field is wired as a combobox through a new PromptInput fieldProps passthrough (additive). Free text goes to the plugin the settings name as assistant; its handler runs with an abort signal and Send becomes Stop meanwhile. With no assistant set the bar says so instead of guessing. /open <plugin> [value] is a host command over manifests alone: a navigator, an app's single page, or a one-param document. It completes and runs before the plugin's code has loaded.
…he URL A link names one document by the plugin's declared route. The route's loader resolves it against the layout restored from storage: open if absent, focus if present, never a duplicate. A link to an unknown plugin, a plugin without pages, or a path its route does not match is announced and lands on /workbench with the layout untouched. The other direction: when a document gains focus its path becomes the URL, pushed if it was just opened and replaced if it was already open, so Back walks through what was opened rather than every click. Navigators never touch the URL. Closing the addressed document replaces the URL with the next focused document's path, else /workbench. A write equal to the current location is skipped, which also ends the URL→layout→URL cycle. The shell is drawn once by the _workbench layout route; /workbench and /p/… only run loaders beneath it, so a URL change never remounts panels. Focusing a panel that is already focused and active is a no-op in the reducer, so a resolved link produces no layout change.
/customize (also in the Workbench menu) enters the mode the store already knew: the layout at entry is held aside, every rearrangement in between is one undo step on Keep, and Discard restores the entry layout. The status bar carries the Keep and Discard controls while customizing, and the sidebar shows unpinned plugins inline so a pin is one click rather than a trip through More.
…uild Droppable. Ported from the fork's plugin-host work and reshaped for the panel contract. The host asks GET /plugin-registry/plugins for manifests at startup, drops invalid ones, and installs the rest as remote plugins whose module loads over Module Federation (registerRemotes + loadRemote) on first use. Bundled plugins win over same-id registry entries; a registry that is down leaves them working. The path is same-origin: nginx proxies it to REGISTRY_UPSTREAM when set and answers an empty list otherwise, so script-src 'self' stands; in dev a Vite middleware serves the bundled manifests. The SDK gains the shared-singleton list (react, react-dom, zod, the design system, the SDK itself) and a pluginFederation() Vite preset for plugin authors. npm run build:plugin-sdk emits dist-plugin-sdk/ as @kbase/plugin-sdk with '.' and './vite' entries; local builds are private. Local plugins import '@kbase/plugin-sdk' through an alias, as external ones will. Unverified: the MF Vite plugin warns that aliased shared modules bypass sharing. Whether a real remote gets the host's design-system and SDK instances needs a remote to test against; until then treat this commit as scaffolding.
The workbench README covers vocabulary, the directory fences, the layout model and its invariants, operations/announcements/undo, the provisional sidebar model, commands and the prompt bar, deep links, accessibility, and what the host expects of a registry: endpoint, manifest fields it reads, id rules, behaviour per failure, what an error boundary does not contain, and what is deferred. The SDK README ships in the package.
The workbench and /p/ deep links run entirely on mocked plugins, so the root gate lists /workbench and prefix-matches /p/ until real plugins carry user data.
PromptInput grows a footer slot (controls row under the field, left of Send, like an email's To line) and fixes the flush hint selector, which never matched past the sr-only status span. Popover.Popup passes anchor, side, sideOffset, align and alignOffset through to the positioner, for controlled popovers opened by other controls. Tabs export tabClasses for strips the component cannot host, gain an opt-in dividers modifier, and drop the selected bold that shifted tab widths. Section 12 documents Tabs, which had no showcase entry.
The sidebar is one accordion card: pinned navigators as sections met by hairlines, headers in the accordion pattern (plugin icon, bold title, right chevron, raised tone), closed by a More footer row whose trailing cluster shows the unpinned plugins' icons. Collapsing animates the same mounted tree into an icon rail (clip widths, --t-slow, reduced-motion aware); rail flyouts open beside their icon at sidebar width, top-aligned and hung 6px, at popover elevation. More previews an unpinned navigator without pinning: an ephemeral dashed block in the stack, or a flyout when collapsed. The composer spans only the main column and carries its destination row inside the card via the new usePromptContext SDK hook: the assistant names where free text lands, offers switching before send, and a jump to the document. Koros implements it against its current arc, with the setCurrent render-phase mutation moved into an effect. Tab strips render the DS tab classes (dividers, plugin glyphs at currentColor, close on hover and selection only); panels focus on pointerdown so clicking prose moves workbench focus. Chrome sits on the page background with content in surface cards; focus and selection use the system's tint pair. The catalog rows align on a grid with the same icons. Collapse lives in the status bar.
Customize mode granted nothing: every rearrangement worked in use mode too, and per-operation undo already covered the safety story, so the mode's whole yield was Keep/Discard bundling that its own author could not find a use for. Gone: the store's mode and enter/commit/cancel, the /customize command, CommandContext.mode (never read by a command), the status-bar controls and the customize-only add strip. In its place, a persistent lock with a real job: layout.locked (schema- defaulted so saved layouts still parse) makes reduce refuse move, resize, pin and unpin while open, close, focus, fold, bars and collapse stay free. Toggled by /lock-layout and a menubar checkbox; the status bar notes the state; drags are disabled at the source and splitters hidden while locked. The toggle is not an undo step.
The counter-animating clips let flexbox squeeze what they clipped: rail icons were revealed from centre (reading as a size change) and inner widths re-laid-out every frame. Now the container's width is the one animated layout property; the rail is a fixed-width overlay and the blocks keep their full width, cropped by the container, so nothing inside moves during the slide.
The router and query badges cover real UI in every dev session; VITE_DEVTOOLS=true in .env.development.local brings them back.
The Loader at rest is the mini-logo; static until something wires its active state to loading.
Brand logo, accordion header icons and rail icons all centre on x=24: the rail widens to 48 and the header rows' left inset drops to --s-3. Previously three columns, three lines (24, 26, 21).
Expanded, the sidebar scans as rows and the logo on the header icons' line (x=24) reads as a wordmark. Collapsed, everything below is a bare glyph column and an aligned logo gets scanned as its first member; it slides to x=14 with the drawer, marking where chrome ends.
Mark at the masthead's dot scale (Loader size 42: its 48-unit viewBox renders dots at 18/48 of size), --s-5 to the wordmark, name at --fs-7 bold ink2 — half-scale before, which is what made the lockup feel off. At this scale the mark reads as a logo rather than another glyph in the collapsed rail's column, so the state-dependent brand shift goes.
Per wordmark size T: dots 1.0 T, gap 0.75 T, name one type step above its surroundings. The menubar's triggers are --fs-4, so T is --fs-5 — 14px dots (Loader 37), --s-4 gap — rather than the hero masthead's absolute 16/12/16.
Function Junction pairs the canonical mark (24 high, 16px dots) and the --s-5 gap with breadcrumb-scale text: chrome.css's 'sizes included' means those two are fixed and only the text adapts to context. The last change wrongly scaled all three; the mark is back to canonical with the wordmark staying at menubar scale.
The canonical masthead was sized for chrome.css's .kb-appbar (--s-3 vertical); in a 4px-padded menu strip it read wrong at every scale tried. The container was the misfit, not the mark.
A manifest command with `shortcut` (true, or a short button label) and an optional `icon` becomes a button in a slim panel above the sidebar's accordion — run from the manifest alone, so it works before the plugin's code loads. Collapsed, the toolbar is the rail's ⚡ icon with the same buttons in a popover. Koros flags new-question as "New arc". The brand returns to the thin menubar's small lockup.
Pinned-only was an invented gate; the toolbar is the manifests' flagged commands, full stop.
One tab stop with arrow-key roving from the component instead of a div with a role attribute; the collapsed popover uses its vertical orientation.
Same fix as the shortcut bar: Toolbar.Root vertical with the NavIcons as Toolbar.Buttons composed into their popover and menu triggers, so arrow keys rove the rail instead of a role attribute pretending they could.
New arc wore the toolbar trigger's own Lightning; a command without its own icon now wears its plugin's, which also states whose action it is in a mixed-plugin toolbar.
New question, the plugin's shortcut, opens an arc with no question yet as its own tab, and the prompt bar targets it; the first message sent is the question and names the arc, as KIND*AI names an arc from its question. An empty arc already open is reused. The destination menu offers New with a chat-bubble-plus icon, which opens the same page: an option may carry a manifest icon now, and select receives the host to open it. An explicit ask for the prompt bar sets the focus intent, so it outranks the focus that follows a command to the panel it opened.
Every assistant has a conversation to start, so the prompt module carries newConversation and the destination menu offers New itself, first, with the chat-bubble-plus glyph; the plugin's options follow. An option no longer carries an icon, and select is as it was. KOROS answers with a new arc page.
Text that is not a slash command is now matched against every manifest's commands, and the closest are shown as rows under the plugins' own offers, with arguments filled from the identifiers the text carries: "I want a dossier for P0AEX9" reaches function-junction:open with q=P0AEX9 whether or not that plugin recognised the text. Two things changed for that. The workbench now tags identifiers in text by shape, under the prefix Bioregistry gives them (uniprot:P0AEX9, ncbitaxon:562, go:0008150), and those terms join the pool beside what plugins mint. This reverses the documented rule that the workbench does not interpret text itself. The shapes are 42 prefixes extracted from Bioregistry v0.14.5 by scripts/build-shapes.mjs; an entry whose registry pattern is a bare integer is tagged only in its prefixed form, since 767 registry patterns match "562". Plugin regexes are anchored on the whole query and so never saw an accession inside a sentence; the tagger scans tokens. A command declaration gains an optional `semantics` section, indexed and never shown: what the command does in the words a user would type, and example phrasings. Optional and additive, so the SDK version is unchanged. Ranking is character 2-5-gram TF-IDF cosine over the declaration, with a tagged span read as the registry's name for its prefix. On a synthetic 83-command set it scored 0.80 top-1 / 0.92 top-3; BM25 fusion measured within a point and was left out. The floor of 0.2 kept 96% coverage there. Arguments are bound by the same cosine between the prefix's registry words and each argument's description, so a plugin that mints `taxon:` and one whose argument says "NCBI taxid" need no shared list.
The command ranking and identifier tagging the previous commit put in the
host move behind a sixth plugin module, `intent`, chosen in Settings the
way the assistant is. The workbench is back to passing text along and
reading none of it.
interface Intent {
index: (commands: DeclaredCommand[]) => void; // once, as the module arrives
suggest: (q: Query) => Suggestion[] | Promise<Suggestion[]>; // every keystroke
}
`index` is separate from `suggest` so that no keystroke sees the catalog:
a provider builds whatever it wants from the declarations once, and a
keystroke costs it one short vector and a dot product per command. One
provider runs, named by the new `intent` setting (default: the bundled
one); a saved settings copy that predates the field keeps the default.
Suggestions live on the typing source's state, land at once when sync and
on arrival when async, stay until replaced, and are dropped by the signal
when the text moves on. The prompt bar shows them under plugin offers.
The bundled provider is the local plugin `intent`: its background tags
identifiers by shape under Bioregistry prefixes, its intent module ranks
by character n-grams over the declarations. Both modules and their tests
move from src/workbench/core to src/plugins/local/intent unchanged in
behaviour, and scripts/build-shapes.mjs writes there. A new module kind
is a contract change, so the SDK is 0.2.0; 0.1.0 stays accepted, since a
plugin built with it lists nothing this host cannot load.
Left out: coalescing keystrokes within an animation frame. The pool is
computed synchronously on set() and tests rely on that; coalescing it is
a separate change if the per-keystroke fan-out ever measures as a cost.
A plugin's recommend.commands is the deterministic layer: for a term it recognised, the command it would run. The intent is the layer that reads the rest of the sentence. Until now the two were two lists, offers above suggestions, so "compare taxon:562 with Bacillus" put genKnown's open, fired by the term, above the compare the sentence asked for. Now the host hands the offers to the intent in `query.offers`, each command qualified, and the intent returns one list: each offer kept, moved or left out as it judges, in the plugin's own words. The bar shows the offers as the plugins made them only when no intent is chosen or the intent answers nothing. For the offers to be in hand on the keystroke, the typing source no longer waits for the 250 ms settle: every recommend.commands is asked as the text changes, and the intent is asked once with the synchronous answers. A plugin that answers later lands when it does and the intent is asked again. The settle stays for the page and cart sources, and cartItems is never asked for typed text. recommend.commands is therefore documented as answering from the terms alone, without I/O; Function Junction's and genKnown's already do. The bundled intent scores an offered command by the sentence plus a small lift, since a plugin recognising its own identifier is evidence. The lift reads letters, not context: an offer for an identifier that is a coincidence in the sentence is ordered low, not left out, and the code and docs say so. Leaving it out needs a scorer that reads the sentence.
A row the bundled intent makes for a bound argument read as the command's title, written for the slash list, with the identifier at the end. Now the identifier is the label and the caption is the title and the plugin. A plugin's own offer keeps the words its author gave it. Suggestion gains an optional detail for the caption.
# Conflicts: # src/design-system/components/Button/Button.module.scss # src/design-system/components/Button/Button.tsx
The design system's CartButton decides the looks and the two forms; the SDK's binds it to the host's cart: pressed is whether the item is in it, pressing adds or removes, and the name says what is carried. The labelled-only stance is gone with it; a tight row takes the icon form.
The three dark grounds sat within six lightness points of each other (bg .193, surface .2237, raised .2534), so a card on a pane in the sidebar read as one continuous field and separation fell entirely to the 1px borders. Reviewers consistently called the result flat. Take them to .16 / .215 / .26: a ten-point spread instead of six. Nothing else moves. --c-ink4 keeps the Lc 67 its comment promises (67.30 on the new raised, measured), the tint families still clear raised by .025 against the .032 they had, and every ink stays far above AA on every ground -- the tightest pair is ink5 on raised at 5.52:1, and ink5 is decorative. Light mode is untouched. raised lands at .26 rather than .27 deliberately: .27 would have pulled ink4 to Lc 66.89 and squeezed tint clearance to .015, which costs seven further token changes to repair for one hundredth of extra spread.
Button's min-height became the tier's control height, 34px at md, after the density merge, and the pill, written before it, stretched to an oval. The pill is its own size. The SDK's binding shows the design system's words, Add to cart, and carries the subject in the accessible name only.
Browse everything no longer trails the rows as an escape; Home's browse command carries semantics and is ranked when the text asks for it. A suggestion runs when pressed, so the intent offers a command only with every required argument filled: kill the running job names no job and gets no row.
"cancel job 12" carried no job:12, so the intent had nothing to bind and the row was dropped for its unfilled argument.
A command name typed as /Cancel resolves like /cancel; arguments keep their case. What to try walks the current path: a sentence ranked into command rows, the dossier and the Related panel, the cart, and a KOROS question from New question, with slash commands and Settings' ranking choice under Then, whenever.
dauglyon
force-pushed
the
workbench-prototype
branch
from
September 10, 2026 16:59
fb1436c to
4a63153
Compare
The tagger matches every shape case-insensitively and mints the id as the registry writes it, upper for a shape that names only upper-case letters: p0aex9 is uniprot:P0AEX9. Data matches a typed ref the same way. Function Junction and genKnown do the same in their own classifiers, on their PR branches.
dauglyon
force-pushed
the
workbench-prototype
branch
from
September 10, 2026 18:39
241ce4e to
3931a1c
Compare
Five things to do and what appears after each, then a short list of more to try. No mechanism and nothing named after the code.
An offer is the plugin saying it recognised the text. The floor is for commands matched by letters alone; an offer whose letters say nothing, a bare orthology id against Diaspora's command, was being dropped, and the row went to whichever plugin's words happened to score.
The example is a protein all three real apps answer for. Each step says what to press and what appears, including the wait while the dossier builds and the rows arriving in Related; KOROS's step says it is a mock-up and what the real one would do there.
The sidebar, the status bar and the menubar carry data-density=compact, and the menubar's popups, which portal out of it, name the sm tier. The rows the workbench draws itself, block headers, the More row, Related rows, completions and status items, read the role tokens (--row-py, --item-py, --ctl-px, --ctl-fs) rather than restating a size, so a tier change reaches them. Layout spacing between regions is left as it was.
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
UX prototype of the pluggable workbench shell: sidebar, dockable tab groups, menubar, bottom AI prompt bar with slash commands, status bar. Five plugins are mocked locally over a small contract, the plugin SDK is shaped for extraction but not published, and `src/workbench/README.md` specifies the host's side of the registry API. The registry service, SDK publishing, and real remote loading are out of scope; the Module Federation port is the last commit and can be dropped on its own.
Vocabulary: workbench = shell; layout = saved arrangement. `workspace` stays the KBase data service.
Commits
Review focus
The sidebar model is provisional: pinned plugins' navigators stack with dividers, fold to header, never hide; the icon column is the pinned list 1:1; unpinned plugins pop out from More. `npm run dev` → `/workbench`.
Known
Verification
`npm run typecheck && npm run lint && npm run build && npm run build:plugin-sdk` clean; `npm test` 282/283 (the Tooltip flake above).