Skip to content

Workbench prototype: pluggable shell, five mocked plugins, plugin SDK - #46

Open
dauglyon wants to merge 175 commits into
kbase:ds/workbench-componentsfrom
dauglyon:workbench-prototype
Open

Workbench prototype: pluggable shell, five mocked plugins, plugin SDK#46
dauglyon wants to merge 175 commits into
kbase:ds/workbench-componentsfrom
dauglyon:workbench-prototype

Conversation

@dauglyon

@dauglyon dauglyon commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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

  1. Core: `Layout` schema, operations, pure reducer, announcements, snapshot undo store. ESLint fences `core/` and `commands/` from React.
  2. Commands: registry keyed by slash name, parser and completion, keybinding chords avoiding browser-owned keys.
  3. Shell in use mode: menubar, sidebar, tab groups, prompt bar, status bar, one live region, per-panel error boundary, localStorage restore.
  4. Pointer drag via dnd-kit dispatching the same move operation as keyboard and menus.
  5. SDK contract (manifest schema, `definePlugin`, `usePanel`/`useHost`) and the host index that loads a module on first use.
  6. Plugins: koros (assistant, projects → arcs, `/arc/$slug`), data (Data home with KBase 1.0 bridge, `/data/$ref`), jobs (`/job/$id`, `/cancel`), function-junction and genknown (iframe apps), plus the host catalog. App iframes live in a fixed layer so a move does not reload them.
  7. Prompt bar completion, assistant dispatch with Stop, `/open` before any plugin code loads.
  8. Deep links: `/p//…` resolves open-or-focus; document focus writes the URL; navigators never do.
  9. Customize mode: one undo step per session, Keep or Discard.
  10. Module Federation port (droppable): registry fetch, remote loading, nginx proxy with empty-list fallback, `build:plugin-sdk`.
  11. Docs.

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

  • The MF Vite plugin warns that aliased in-repo shared packages bypass sharing; needs a real remote to verify.
  • Design-system `Tree` rows are clickable divs, so tree navigators are pointer-only; a design-system fix, not made here.
  • `Tooltip.test.tsx` (untouched) times out under full-suite load and passes alone.

Verification

`npm run typecheck && npm run lint && npm run build && npm run build:plugin-sdk` clean; `npm test` 282/283 (the Tooltip flake above).

David Lyon and others added 30 commits September 4, 2026 02:39
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.
dauglyon and others added 15 commits September 9, 2026 17:09
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.
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.
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.
@dauglyon
dauglyon changed the base branch from main to ds/workbench-components September 10, 2026 21:31
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant