Bring the workshop up on macOS: Metal voice, dylib provisioning, webview mic capture - #7
Closed
gregjkal wants to merge 124 commits into
Closed
Bring the workshop up on macOS: Metal voice, dylib provisioning, webview mic capture#7gregjkal wants to merge 124 commits into
gregjkal wants to merge 124 commits into
Conversation
Rename the desktop app from Workbench to Workshop across the whole workspace. The crates promptforge-wb and promptforge-wb-server become promptforge-ws and promptforge-ws-server: directory moves, package and binary names, the workspace dependency table, Cargo.lock, and the library path promptforge_ws_server. CI renames the check-workbench job to check-workshop and updates every --exclude flag. The example config workbench.example.toml becomes workshop.example.toml, the two design documents are renamed to match, and the UI modules workbench-provider.ts and workbench-socket.ts become workshop-provider.ts and workshop-socket.ts with their WorkshopProvider and WorkshopSocket classes. Prose sweeps cover the READMEs, doc comments, .gitignore comments, the gateway user guide, and UI comments and error strings. Most files move at 100 percent similarity; where content changed, the edits are confined to name strings. - The WebSocket surface already used the ws abbreviation before this rename and is untouched: the GET /ws route and the chat_ws module mean WebSocket, not Workshop. The new -ws crate suffix collides with that abbreviation knowingly. - Config discovery keeps a migration path: the server binary reads workshop.toml from the current directory and falls back to a leftover workbench.toml when the canonical name is missing; the desktop shell tries workshop.toml then workbench.toml at each of its three search locations (beside the executable, the current directory, the profile directory), with a regression test. First-run generation writes only workshop.toml. - The old promptforge-wb README shows as a delete plus a new promptforge-ws README rather than a rename, because the density of name changes pushed it below git's similarity threshold.
Add design/what-promptforge-is.md, a 398-line positioning and design paper for the whole system. Its thesis: human intent is the source code, and everything downstream (plans, prompts, reports) is a build artifact to be regenerated rather than rewritten. The paper walks the four layers - the promptforge-core engine that executes Markdown/Lua prompt pipelines, the credential-holding gateway, the toolchain (CLI, MCP server, tool picker, webfetch, dev runner), and the Workbench desktop app - and grounds each design choice in the history that produced it: the Staker, a sixteen-step stakeholder-analysis pipeline run inside a chat harness, and the 120-rule field manual distilled from 89 transcripts. Two measured effects carry the argument. Semantic blur: model rewrites of prompt files grow them without adding capability (one rulebook grew 145.8 percent), so plans should be preserved and artifacts regenerated. The fan-out asymmetry: models evaluate more reliably than they generate, so the human sits in the judge's seat. The Workbench sections describe an append-only hash-chained event store as the product, an identity model (ExecutionFingerprint, PromptVersion, stable block IDs), plan mode as an entropy filter, a leaderboard replacing the tree view, on-device voice, and a sidecar critic, then explicit non-goals, a six-stage build order, and open questions. - The document calls the desktop app the Workbench throughout; its footer dates the text 2026-08-24 and credits Kimi K3 (Cursor agent) as the drafting model. - Quantitative claims cite internal reports (the semantic-blur measurement, an August infrastructure analysis), and the softer numbers carry inline confidence tags; the 6-to-10x wall-clock speedup, for example, is marked estimate, medium confidence. - Documentation only; no code changes.
The voice plugin kept a .voice-status div under the composer that grew
from zero height through a max-height transition every time it had
something to say, shifting the composer up and letting it drop back
eight seconds later. Recording state was already carried twice, by the
red mic button and the status bar's REC badge, and the routine
confirmations ("Recording...", "Transcript ready", "Recording
discarded") repeated what the user could already see. The div, its
CSS, and the showVoiceStatus helper with its auto-hide timer are gone;
setupVoice no longer receives a status element.
The messages worth keeping are genuine failures: microphone permission
denied, voice capture unsupported by the browser, a dropped voice
connection, capture failure, plus the no-speech-detected note. These
now paint the bottom status bar through a new
StatusBar.showLocal(label, severity), which writes the bar text and
error styling directly.
- showLocal is deliberately transient with no timer of its own: the
next status frame from the server's observer overwrites it. The old
8-second auto-hide timer was one of the pending timers that forced
the smoke test to exit explicitly rather than wait them out.
- Two severities shifted in the move: no-speech-detected is now plain
info, while an unrecognized frame from the voice socket is reported
as an error instead of neutral text.
- The smoke test now asserts that no .voice-status element exists
after the voice plugin mounts, so the removal cannot silently
regress.
The workshop window is a wry webview pointed at the loopback server, so clicking a link in an assistant reply navigated the app away from itself with no way back. Two layers fix it. In the UI, the HTML sanitizer now stamps target="_blank" and rel="noopener" on every anchor that kept an href. In the desktop shell, a wry navigation handler classifies each navigation target: loopback http(s) URLs (127.0.0.1, [::1], or the literal localhost host) load in the webview as before, while any other absolute http(s) URL is handed to the system browser through the open crate and the webview stays put. The classifier is a pure function with Rust unit tests, and the UI smoke test asserts the target and rel attributes on a rendered reply link. - target and rel are set after the sanitizer's attribute sweep, so author-supplied values (already stripped as unsafe) can never override them; an anchor whose href failed the URL check keeps no href, stays inert, and therefore gets no target either. - Non-http schemes (about:blank, data:) and unparseable or relative targets are deliberately left to the webview, since blocking those would break the app's own internal navigation. - localhost is matched by exact case-insensitive domain comparison, so a host like localhost.evil.example classifies as external (covered by a test); a failure to spawn the system browser is logged to stderr and the navigation is still blocked rather than falling through into the webview.
Streaming assistant text was re-parsed in full by marked on every throttled update, so rendering cost grew with the square of the message length. The new StreamingMarkdownRenderer in markdown-blocks.ts splits the text into top-level blocks at blank-line boundaries (code-fence aware, so blank lines inside an open fence never split), renders each block into its own segment div, and memoizes by rendered source: completed blocks parse exactly once, and only the still-growing tail block re-parses on each update. Before parsing, unterminated markdown on the tail is repaired so a partial construct renders as its intended element instead of flashing raw syntax: an unclosed code fence is closed, unbalanced bold and italic markers are balanced (escaped markers ignored), an incomplete link destination is closed, and a partial table delimiter row is completed from the header's column count. MessageNode delegates its markdown path to the renderer; every segment still passes through the renderSafeHTML sanitizer as the final pass. A jsdom unit test uses a parseCount instrumentation field to prove completed blocks are never re-parsed, and joins npm test. - Healed text is a rendering aid only and is never written back to the message. The memo key is the post-repair source, so a tail whose healed text equals its raw text is not parsed again when the message finalizes. - Fence healing short-circuits the other repairs, because inside an open fence the remaining text is literal. - The generating-caret CSS selector needed one more descent level (.mur-md-segment:last-child) since block content now nests inside per-segment divs.
The ThinkingPlugin shipped dormant with the vendored murm-ui chat code. Register it in main.ts and rebuild it around three modes. While reasoning streams, the block auto-opens into a preview capped at roughly four lines that scrolls internally and stays pinned to the newest line; the toggle label reads "Planning next moves..." with a compositor-only shimmer (background-position is the only animated property). When the first content token ends the reasoning stream, the block auto-collapses once to the label row, capturing and restoring the feed's scroll position across the layout change so the feed does not jump. Clicking the toggle expands the full thinking text. A grace-period loader covers slow starts: if generation runs about 500ms with no block of any kind, a synthetic non-interactive status row with the streaming label appears on the last assistant message, so fast responses never flicker it. A jsdom unit test covers the loader timing, the preview, pinning, auto-collapse, and expansion, and joins npm test. - A manual toggle is sticky: after one click, auto open and collapse stop for that message. Preview auto-pin disengages when the user scrolls up inside the preview and re-engages within 8px of the bottom. - The grace row is cleared by any block rendering into its message, not just reasoning blocks, so a text-first response removes it too. - Mode transitions are announced through a visually hidden aria-live span; per-token reasoning text never is. The preview cap rule sits after the agent-run overrides in the stylesheet so it wins at equal specificity inside work segments, and the shimmer goes static under prefers-reduced-motion.
The ToolsPlugin shipped with the vendored murm-ui chat code but was never registered. Register it in main.ts and rebuild it around a per-run activity block: consecutive tool_call blocks in a message fold into one collapsible group. Collapsed, which is the default, the header is a fixed-height one-line status window; each new activity line animates the previous one up and out, and once every call settles the line rests on a summary such as "3 actions completed", with failed calls counted separately. Expanding reveals the preserved log: one row per call with a status icon, a human-readable label built from preferred argument keys, and a chevron that unfolds Arguments and Result sections. The plugin is split into focused modules: tool-context pairs each tool_call with its tool_result (which may live on a later message) and caches the pairing, tool-format builds labels and summaries, tool-row renders one log row, and tool-run-group owns the collapsible block. A jsdom unit test drives the whole flow and joins npm test. - Run grouping leans on engine render-pass semantics: blocks arrive in order once per state change with a fresh messages array, so the array's identity marks a new pass. The open-run table resets exactly then, which guarantees a member block always finds its run leader already registered earlier in the same pass. - Member containers stay hidden while their rows live in the leader's preserved log; if a leader stops being first in its run, its group dissolves and the members rejoin on their own renders later in the same pass. - The one-line window animates only transform at constant height, so the feed never shifts; the animation is skipped under prefers-reduced-motion, and the aria-live region announces resting summaries only, never per-activity lines.
The web UI gains a custom window title bar for the Windows desktop shell: a semantic header in index.html with the program icon, File/Edit/Window/Help menu buttons, a draggable empty center, and Minimize/Maximize/Close controls with inline SVG glyphs in the standard Windows order. The bar ships hidden, and the new window-chrome.ts reveals it only when the wry initialization script has set the desktop flag, so a plain browser never shows desktop chrome and never touches window.ipc. Every control speaks to the native shell through one narrow channel: window-chrome.ts builds a typed WindowCommand envelope - a JSON object naming exactly one of drag, minimize, toggle-maximize, or close - and posts it over the wry IPC bridge. Coming the other way, the bar listens for the shell's promptforge:maximized CustomEvent and validates its detail field by field before swapping the maximize/restore glyph and aria-label, so a malformed event changes nothing. - The build pipeline learns nested static assets: build.rs and ui/build.mjs (mirrored STATIC_FILES lists) now create parent directories before copying, because icons/promptforge-icon-1.png is the first asset living in a subdirectory. The server routes it at /icons/promptforge-icon-1.png with an image/png content-type test. - The menu buttons are inert placeholders here; a follow-up commit wires their popovers. The drag region only posts a drag command on a primary-button press whose target is the region itself, so presses on the buttons never start a native drag. - If the desktop flag is set but window.ipc is missing (only possible by setting the flag by hand), commands are dropped silently rather than throwing from a click handler.
The title bar's placeholder menu buttons become working application menus. The new window-menu.ts builds an accessible HTML popover behind each button (role="menu", aria-expanded, role="menuitem" rows with right-aligned shortcut hints), with one menu open at a time, dismissal on outside pointer-down, and full keyboard support: ArrowUp/ArrowDown move within a menu, ArrowLeft/ArrowRight step across menus, Escape closes and returns focus to the button, Enter activates the focused row. In a plain browser (no desktop flag) only the command set is built; the popover DOM wiring is skipped entirely. Every action dispatches through a single WindowMenuCommands set, so future keyboard shortcuts and any other menu surface call the same functions. The Window menu reuses the visible window controls' actions, which window-chrome.ts now exports as named minimize, toggle-maximize, and close functions. File offers New Chat (through the ChatUI session API) and Close Window; Help opens a new About dialog - a focus-trapped, Escape-dismissable modal naming the product, version, and license that restores focus to its invoker when dismissed. - Edit commands (undo, redo, cut, copy, paste, select all) go through document.execCommand rather than the async Clipboard API: WebView2 hosts the page as application content with clipboard access, and execCommand preserves the editable target's native undo stack and selection semantics. jsdom leaves execCommand undefined, so a guard keeps the commands no-ops under test. - Clicking a menu button moves focus to the button, so the edit target is remembered continuously through a document focusin listener instead of being read at open time; Edit rows render aria-disabled when no connected editable element has been focused. - APP_VERSION in about-dialog.ts mirrors the workspace Cargo.toml version by hand; the two must be bumped together.
Replace the structural placeholder CSS for the title bar and its menu popovers with the full visual treatment; no markup or script changes. A --titlebar-* variable block joins the :root skin tokens (bar height, foreground, divider, accent, glyph and hover colors, control width, icon and font sizes, popover width and shadow), each derived from an existing palette token with the stock value as fallback, so a future skin can restyle the whole chrome by overriding variables. The bar renders as a 40px near-black strip with a one-pixel bottom divider: decorative program icon, text menu buttons flush left, a flexible drag center, and three equal-width window controls with muted SVG glyph strokes. Hovers flip instantly like native chrome - a neutral wash on the menus, Minimize, and Maximize, and the conventional red fill with a white glyph on Close - and every control takes a one-pixel inset purple accent focus ring. Popovers become raised near-black surfaces with a drop shadow, a 200px minimum width, and compact rows with the shortcut right-aligned in smaller muted text. - The bar's [hidden] rule must keep winning over the new base rule's display:flex; the attribute selector's extra specificity guarantees it, which is what keeps the bar out of plain-browser layout now that the base rule sets a display. - Disabled menu items get their hover wash suppressed (aria-disabled="true":hover resets to none) so they read as inert. - A new titlebar-style.mjs test file pins the skin rules, so token wiring regressions fail the UI test run instead of only visual review.
On Windows the shell drops the native window frame (with_decorations(false)); macOS and Linux keep their decorated windows. The custom HTML title bar is now the window's only chrome, so the shell must accept window commands from the page: the event loop is rebuilt with a user-event type, a wry IPC handler parses each incoming envelope, and valid commands travel through an EventLoopProxy to run on the event loop thread, where the tao Window actually lives. Drag starts a native window drag, Minimize and ToggleMaximize call the matching window methods, and Close exits the run_return loop, so the in-process server behind the webview still shuts down cleanly through the normal return path. State flows back the other way too: every Resized event dispatches a promptforge:maximized CustomEvent into the page via evaluate_script, because every maximize path - the button, a title bar double-click, Windows Snap, and restore - surfaces in the loop as a resize, making Resized the one reliable hook for keeping the glyph in sync. - parse_window_command is deliberately paranoid: malformed JSON, missing or mistyped fields, and unknown command names all return None, so no unrecognized payload can ever reach a native window operation. Unit tests enumerate the four valid envelopes and ten rejected shapes. - The IPC handler runs on a webview thread with no access to the tao Window, which is why commands are enum values forwarded by proxy rather than handled inline. - A wry initialization script sets window.__PROMPTFORGE_DESKTOP__ so the page can tell the desktop shell from a plain browser; serde_json joins the shell crate's dependencies for envelope parsing.
The desktop shell window now carries the PromptForge program icon (the cold medallion frame) instead of the OS default. The PNG is embedded at compile time with include_bytes! so the installed binary carries no asset files, decoded once at startup with the png crate - a new workspace dependency at 0.18 - and installed through WindowBuilder::with_window_icon. Decoding is strict but failure is soft. decode_png_rgba accepts only 8-bit RGBA output, matching the bundled asset, so the pixel format handed to Icon::from_rgba is effectively fixed by the asset itself. window_icon() maps any decode or conversion failure to a logged message and None, so a bad asset can never block startup - the window simply keeps the default icon. - All five heat-stage frames (promptforge-icon-1 through -5) land in crates/promptforge-ws/assets/icons, but only frame 1 is embedded and shipped; the other four stay on disk, reserved for a future activity animation that swaps the icon as the forge heats. - Unit tests pin the bundled asset itself, not just the code path: the embedded bytes must decode to exactly 128x128 with a full RGBA pixel buffer, and junk or empty input must fail. Re-exporting the icon in the wrong size or color type therefore fails cargo test instead of silently shipping a broken icon.
The workshop server gains filesystem access, deliberately jailed. A new workspace.rs module holds an in-memory set of granted roots - a dropped folder grants itself, a dropped file grants its parent directory - and mounts four routes: GET /workspace/tree lists one directory level (or the granted roots when no path is given), GET /workspace/file reads a file, PUT /workspace/file writes one, and POST /workspace/grant registers a root. Grants live only for the running process; persisting them across runs is a separate future consent decision. Confinement is two layers, patterned on the gateway's artifact-cache confine.rs: a lexical rejection of ".." components and ":" alternate data stream names first, then canonicalize-and-prefix-match against the canonical grants before any filesystem operation, so traversal, symlink escapes, and UNC aliases cannot reach outside a grant. Reads cap at 1 MiB and refuse binary (NUL-containing) and non-UTF-8 files. Writes carry a modified-time conflict token: when the target exists, expected_modified_ms must equal its current mtime or the write is refused, preventing silent lost updates from concurrent editors. - Writing a new file cannot canonicalize the file itself, so the parent is canonicalized and the name reattached - and a dangling symlink at the target is refused as outside the grants, because fs::write would follow it and create the target outside the jail. - Every failure renders as a stable JSON envelope with a machine-readable code and a matched HTTP status: 403 for confinement refusals, 404, 400, 415 for binary or non-UTF-8, 413 for oversize, 409 for a stale token, 500 for I/O. - The grant set is an Arc<RwLock<BTreeSet>> shared by clone, so a grant registered through the route is visible to every handler immediately; poisoned locks recover through into_inner.
Dropping files or folders from Explorer onto the workshop window now registers them as workspace grants. The shell installs a wry drag-drop handler that reacts only to the Drop event - Enter, Over, and Leave are cursor feedback the shell does not need - and forwards the dropped OS paths through the tao event loop, since the handler runs on a webview thread with no access to the WebView. The loop then dispatches the paths into the page as a promptforge:file-drop CustomEvent. To carry both payloads on one loop, the existing window-command user event is folded into a new ShellEvent enum alongside the file-drop variant. On the page, the new workspace-drops.ts validates the event detail field by field - only an object carrying a paths array of plain strings is accepted - and POSTs each path to /workspace/grant in order; a per-path failure paints the status bar and does not stop the remaining grants. No file bytes are read on drop; granting is path registration only. In a plain browser the module is inert: the listener is only installed under the desktop flag, and normal HTML5 drag and drop of file contents keeps working untouched. - The drag-drop handler returns true to take over the drop, so the webview never navigates to a dropped file; the page learns the paths only through the typed event. - normalize_dropped_path strips the Windows verbatim prefix (\\?\) and collapses the verbatim UNC form (\\?\UNC\server\share) to the plain \\server\share spelling before dispatch, so the page hands the workspace API the same spelling Explorer shows. Tests cover spaces, unicode names, and both verbatim forms.
cargo fmt --all --check fails on workspace.rs as it landed with the confined workspace file APIs: one assert! in the test module was hand-wrapped across four lines even though its condition and message fit rustfmt's line budget on a single line. rustfmt wants the collapsed form, so the manually expanded call registers as formatting drift and the whole workspace-wide check goes red on this one call site. This commit collapses the assert! to the single line rustfmt produces, restoring a formatting-clean tree so the gate passes again. The change is whitespace only. The assertion's condition, !target.exists(), and its message, "nothing may be written outside the grant", are unchanged byte for byte; only the line breaks move, so no test behavior shifts in any way. - The affected assertion is not incidental: it closes out the confinement test proving that a refused write outside every granted root leaves no file behind on disk, which is the core security property of the workspace module. Worth knowing it still runs exactly as before, since a formatting fix touching a security test is the kind of diff that invites a double take.
The single-panel Dockview setup grows into the workshop layout. A new
zones.ts registry names three zones over Dockview groups - left for
the workspace tree, main for document editors, right for agent chat,
with bottom reserved - and is the only module that talks to Dockview
placement APIs. openInZone places a new panel by the user's recorded
override first, then the panel type's declared affinity; onDidMovePanel
records where the user dragged a panel so reopening it returns there,
and moving a panel back to its affinity zone deletes the override.
When a zone's last panel closes its Dockview group disappears, and the
next open rebuilds the group on that zone's side of the dock.
panel-types.ts replaces main.ts's inline single-component factory with
a static registry: each panel kind declares its zone affinity, default
title, and content factory, and Dockview's createComponent dispatches
through it. The new WorkshopTreePanel lists the granted workspace
roots and browses one directory level at a time through the new
validated workspace-api.ts fetch boundary (every response parsed field
by field from unknown, no casts); activating a file opens an editor
panel in the main zone, keyed and titled by its path.
- The editor panel is a deliberate placeholder that only shows its
path: its registry entry and { path } params contract are already
final, so the real editor later slots in without touching zones or
the tree.
- Tree expansion state and fetched listings are module-level session
caches, so closing and reopening the Workshop panel restores the
tree as the user left it; listings are fetched once per directory
per session.
- An unknown component name renders a labelled placeholder panel
instead of throwing, so a stale layout can never break the dock.
The placeholder editor panel becomes a real editor, split across a seam. The new editor-surface.ts defines the EditorSurface contract (open, text, markSaved, isDirty, onDirtyChange, focus, dispose) and its CodeMirror 6 implementation, the only place in the app that imports @codemirror/*: basic-setup plus search, a dark theme and syntax highlight style drawn from the same :root palette tokens as the rest of the UI, lazily imported language modes for JS/TS, Python, Rust, JSON, Markdown, and YAML plus TOML through legacy-modes, and dirty tracking as an updateListener comparing the live document against the last opened or saved baseline. editor-panel.ts is rewritten against the interface only. It loads its document through the workspace API, marks the Dockview tab title with a dot while dirty, and saves with the server's modified-time conflict token. A save refused as a modified conflict (409) opens a themed, focus-trapped dialog offering Reload - discard the editor text for the on-disk text - or Overwrite, which re-reads the file for a fresh token and writes the editor text over it; a second conflict in that window reopens the dialog. workspace-api.ts gains fetchFile and writeFile with a typed ModifiedConflictError for the 409 case. - open() carries a generation counter so a lazy language-mode import resolving after a newer open cannot reconfigure the wrong document; a failed language load silently leaves the document as plain text. - The panel takes injectable seams (createSurface, readFile, writeFile), so the new editor-panel.mjs tests drive the save and conflict flows without CodeMirror or a live server. - The single-file esbuild bundle inlines the dynamic language imports; the import() structure documents the load boundary rather than actually splitting chunks.
Three pieces of workbench infrastructure land together. Persistence: the dock's serialized layout plus the zone registry's placement state (zone group map and per-panel overrides) are written to localStorage under one versioned key, debounced 250ms off layout changes. Only panel identity is stored; panels re-create through their registered factories on load. Any restore failure - bad JSON, a schema version mismatch, a fromJSON throw - clears the dock and reports false so boot builds the known-good default three-zone layout instead. Lock: one shared boolean, driven from a state-labeled Window menu command and a padlock control on every zone header; while locked, Dockview refuses drags and CSS hides the tab strip and drop affordances. The layout boots locked, and a lock toggle persists immediately since it is not a layout change. Shortcuts: a single app-level keydown listener binds plain-Ctrl combinations only - Ctrl+S saves the active editor, Ctrl+W closes it, Ctrl+B toggles the Workshop tree, Ctrl+Tab and Ctrl+Shift+Tab cycle open editors with wraparound, and Ctrl+Shift+F opens and focuses the tree. - Ctrl+W routes through a new EditorPanel.requestClose: a dirty buffer opens an unsaved-changes dialog (Save / Discard / Cancel) instead of silently losing edits, and Save only closes the panel when the write leaves the surface clean, so a failed or conflicted save keeps it open. The conflict modal was generalized into a shared showPanelDialog helper both dialogs build on. - Unbound combinations fall through without preventDefault, so the browser and CodeMirror keep typing, undo/redo, and in-file find. - The lock freezes user rearrangement only: openInZone and restore place panels identically in either state, and the status bar stays outside the serialized layout entirely.
Whisper falling back to a CPU pass stalls long enough (around half a
minute for a take) that the feature reads as broken rather than slow,
so the mic should not be offered at all in that case. The server
gains GET /voice/capability, answering {"gpu":bool}: true only when
the build carries the CUDA backend feature and a driver probe
succeeds. The voice plugin in the UI asks that endpoint before
mounting anything, and only inserts the push-to-talk button into the
composer when the answer is true.
The driver probe is deliberately cheap: it checks for the presence of
nvcuda.dll under System32 on Windows and /dev/nvidia0 on Linux (both
artifacts of an installed NVIDIA driver), and answers false on any
other OS. No CUDA context is created and no device is touched.
- The client probe fails closed on every path: non-OK status, a body
that is not JSON, a missing or non-boolean gpu field, and network
failure all answer false, and a dedicated test enumerates each
mode. A stalling mic must never appear because of a flaky probe.
- The mic now mounts asynchronously, a microtask or two after the
rest of the composer, so the smoke test polls for the button
instead of querying it right after boot.
- cfg!(feature = "cuda") is evaluated at compile time, so a non-CUDA
build reports false regardless of the hardware it runs on; the Rust
test asserts that direction since CI has no GPU.
Generation feedback previously came from two overlapping sources: message-node's generic three-dot loader on an empty assistant message, and the thinking plugin's synthetic "Planning next moves..." row, which only appeared after a 500ms grace timer. Both are replaced. The prefill row now appears synchronously the moment generation starts, reads exactly "Planning next moves" with no ellipsis, and carries the shimmer animation. The first reasoning token swaps it for the "Thinking" toggle, which streams a capped four-line preview, auto-collapses on the first content token, and remains a durable, repeatedly expandable disclosure after the reply completes. The label simplifies: the toggle reads "Thinking" in every state, and the shimmer lives only on the prefill row. The visually hidden live region now announces "Thinking" on stream start and "Thinking complete" when it ends. A new optional ChatPlugin flag, ownsEmptyLoadingState, lets a plugin claim the empty-assistant loading state; message-node suppresses its generic dots whenever any mounted plugin declares it, so the two indicators can never stack. - The grace-timer machinery is deleted, not zeroed: fast responses used to skip the row entirely, and now every generation shows prefill until the first block renders, whatever its type. - The shimmer class rename from streaming to prefill is enforced by a stylesheet-contract test asserting no --streaming rule remains and that reduced-motion still disables the animation. - The toggle is deliberately click-repeatable both during streaming and after completion; a manual collapse stays sticky across later reasoning deltas.
Every completed model turn now ends with a quiet footer row: a Copy
button that writes the turn's plain text to the clipboard and shows a
checkmark for two seconds, a Fork button that is rendered fully
accessible but intentionally unwired (a placeholder for a future
action), and a semantic <time> element showing a compact relative
timestamp ("just now", "5m ago", "3h ago", "2d ago") whose
hover/focus tooltip shows the absolute completion time and, for agent
runs, a "Worked for ..." duration line.
The footer mounts at the feed-turn layer so both shapes of turn get
exactly one: plain assistant messages append it after their content,
and grouped agent runs attach it to the run element, keyed to the
final message and outside the collapsible thinking/tool work segment.
It hides while its turn is still generating and reappears on
completion. The dormant copy plugin is deleted since the footer owns
copy behavior now, and formatDuration moves out of feed-node into a
new shared format util alongside formatRelativeTime.
- The relative timestamp does not poll: each render schedules one
timeout for the next minute or hour rollover boundary of the
elapsed time, and destroy clears it. The test wraps window timers
to prove no timer survives destroy.
- Updates re-append the footer whenever it stops being the last
element, so work-segment collapse/expand rebuilds cannot bury or
orphan it; the same footer instance survives those toggles.
- Clipboard denial is swallowed by design: the copy simply gives no
checkmark feedback rather than surfacing an error.
The static sidebar (brand, model <select>, description div) is deleted from index.html and style.css, and the workbench now occupies the full window width. Model selection moves into a Model menu between Edit and Window in the title bar. Unlike the other menus, its popover is dynamic: every open rebuilds the rows from a small catalog surface, rendering one menuitemradio row per model with aria-checked state, a fixed-width check column so labels stay aligned, and the model description as the row tooltip. An empty catalog (or a missing surface) renders a single disabled "No models available" row. Selection state moves out of the DOM into main.ts module state (modelCatalog and currentModel). Recording a catalog keeps the current selection when it survives the refresh and otherwise falls back to the first entry or none, then writes the chat engine's request defaults. A failed boot fetch of the catalog now just logs; the menu shows its empty state until a pushed catalog from the server heals it. - Auto-selecting the first catalog entry is a behavior change: submit unblocks without any user interaction, where the old picker relied on the browser preselecting its first option. - MenuHandle.rows had to become mutable, since the Model menu clears and rebuilds its row array on every open while the other menus stay frozen at build time. - The smoke test orders pushed catalogs with the surviving entry second, so keeping a selection is distinguishable from the first-entry fallback, and it now proves selection through the model id carried on the actual chat frame.
The UI previously mounted exactly one ChatUI onto a singleton chat panel from main.ts. Agent panels now carry stable per-agent ids (chat:<uuid>), and a new AgentController owns one ChatUI per tab: it observes the dock's add/remove/active-panel events, mounts a chat onto each Agent panel's .mur-app surface as it appears (whether from File > New Agent or a restored layout recreating its tabs), and destroys that chat when its tab closes, falling back to a surviving agent as the active one. The tab title changes from "Chat" to "Agent". All agents share a single provider, since the workshop socket already multiplexes concurrent chat streams by request id, and they share one model selection, which the controller applies at mount and broadcasts to every live engine on change. File > New Agent always opens a fresh independent tab in the right zone; File > New Chat starts a new session on the active agent, opening one first when none exists. The voice plugin becomes a per-tab factory so recording state in one tab never touches another. - The controller must be constructed before restoreLayout so restored tabs mount their chats; it also sweeps dock.panels at construction so it does not depend on that boot order. - panelIdFor honors a provided agentId, which is how a restored layout reopens the same panel instead of duplicating it; without one it mints a fresh uuid. - The menu layer wraps the agent surface in closures rather than aliasing its methods, because the surface may be a class instance whose methods need their receiver.
The layout lock is removed wholesale: the workbench is meant to be rearranged freely now that Agent panels are tabbed, so a mode whose only job was to freeze user drags no longer earns its complexity. The layout-lock module (the shared boolean, its listeners, and the padlock control rendered into every zone header) is deleted, the Window menu loses its Lock/Unlock Layout command, the .dock--locked styles that hid the tab strip and drop targets are gone, and Dockview is created with locked: false everywhere, so tabs, dividers, and drag targets are permanently interactive. Layout persistence drops the locked field from its storage envelope and bumps the schema version from 1 to 2. A version 1 snapshot (the locked-era envelope) fails the parse and the boot falls back to the known-good default layout; there is no migration, since the only difference is a field nothing reads anymore. The default boot order is now explicit: the Workspace tree anchors the left zone first, then one Agent opens on the right. - The schema bump is the chosen compatibility mechanism: even a structurally valid old envelope is rejected rather than migrated, trading one-time layout loss for zero migration code. - CommandItem.label reverts from string | (() => string) to a plain string; the function form existed solely for the lock's state-tracking menu label, and refreshEnabled no longer re-renders labels on open. - Zone-header right actions are gone entirely, since the padlock was the only control ever rendered there.
Pressing Stop while a reply streamed left the status bar's activity LED lit amber indefinitely. The root cause is structural: aborting a chat recycles the persistent /ws socket (reopening is how the client cancels, there is no cancel frame), so the server's terminal status frame for that chat can never arrive, and the sustained "thinking" LED state set by the last status frame was never cleared. The client now clears its own state at the moment of abort: the workshop socket gains onAbort listeners fired from the abort path, and main.ts wires them to a new StatusBar.clearActivity() that drops both the sustained and pulsed LED states and applies the idle lens. On the server, a failed delta send (client disconnected mid-stream) now emits an idle status update before the relay returns, so any other subscriber still watching the status bus returns to Ready instead of keeping a stale activity LED. - clearActivity also cancels the pending LED pulse timer; without that, a timer armed by the stream could re-light the LED after the abort. The smoke test waits 400ms after stopping to catch exactly that re-arm. - clearActivity touches only the LED: the text, tooltip, progress, and REC badge belong to other flows and are left alone. - The stop button is type=submit with no click handler of its own, so the test drives it by re-dispatching the form's submit event, and it waits for the chat frame on the wire rather than the button label, which flips synchronously before the request exists.
The custom title bar was gated on the desktop shell's __PROMPTFORGE_DESKTOP__ flag, but the bar is also where the File, Edit, Model, Window, and Help menus live - so serving the UI to a plain browser produced an application with no menus at all. The bar is now revealed unconditionally. Only the native window-control cluster (drag region, minimize, maximize, close) remains desktop-only, because those controls need the wry IPC bridge to act on a real window; in a browser the cluster is hidden rather than shown as dead buttons. setupWindowMenus loses its browser-mode early return, so all five menu popovers are built and wired in both modes. The native window commands reachable from menus (Minimize, Maximize/Restore, Close Window) become deliberate no-ops in a browser: the missing-bridge guard in postWindowCommand, previously documented as unreachable, is now the normal browser path, and dropping the command beats throwing from a click handler. - Hiding the control cluster with the hidden attribute required a CSS rule: the cluster's own display: flex would otherwise override the user agent's [hidden] rule and keep it visible. - The smoke test runs the full menu path with no window.ipc defined, so a passing run proves the browser-mode wiring never touches the bridge; the unit tests additionally click Minimize and Close Window in browser mode and assert nothing is posted. - Menu commands that do not need the bridge (New Chat, New Agent, Edit commands, About) dispatch identically in both modes through the one shared command set.
The Thinking block never appeared because the server discarded the
reasoning side channel entirely: delta parsing extracted only
choices[0].delta.content, so reasoning models' scratch work vanished
before it ever reached the client. The parser now returns both fields
of a streaming delta, accepting reasoning_content plus the reasoning
and thinking synonyms (first non-empty wins, matching
promptforge-core's normalization), and forwards each one as a
{"type":"reasoning"} frame. The websocket client surfaces these
through a new onReasoning handler, the provider turns them into
reasoning_delta events in their own block, and the thinking plugin
renders them.
Two UI behaviors change alongside the relay. A turn whose only "work"
is reasoning no longer collapses into an agent-run work segment: the
feed only builds an agent-run item when a tool call is present, so
reasoning-only replies render as plain messages with an inline,
durable Thinking toggle. And the "Planning next moves" prefill row no
longer races the feed render: message elements now carry a
data-message-id, and the prefill attaches to the generating message
by id instead of grabbing the last assistant element.
- Reasoning is deliberately kept off the tape: only answer content is
assembled into the recorded response, and a server test pins that
contract.
- A reasoning frame marks the pending chat as started, so a socket
that closes after streaming only scratch work resolves instead of
rejecting.
- The store notifies selectors before the feed's hot render creates
the message element, so the prefill attach defers behind a
microtask and retries across several frames, with a token
invalidating attempts once generation moves on.
On Windows, wry's drag-drop handler revokes WebView2's IDropTarget, which disables all HTML5 drag-and-drop inside the page - Dockview panel dragging included. The shell now wraps Chromium's own drop target instead of letting wry replace it: a new delegating IDropTarget in drop_target.rs forwards every drag call to Chromium's target, so panel drags work again, while CF_HDROP paths are observed on Drop and still feed the existing promptforge:file-drop grant flow. The page side suppresses the browser's default file-drop navigation itself, so a dropped file never navigates the app away. The commit also refactors the chat WebSocket handler: the duplicated per-chunk forwarding of reasoning and content deltas is extracted into a forward_payload helper with a small Forward result enum, and the client-disconnected cleanup into its own function, with no behavior change. - Installation of the delegating target retries on a timer, because Chromium registers its own IDropTarget on the WebView2 child windows asynchronously; a one-shot install at window creation would find no target to wrap. - The new windows crate dependencies are deliberately not workspace = true: the crate mirrors the workspace lints with unsafe_code lowered to deny, and drop_target.rs alone opts back out, keeping unsafe scoped to the COM-facing module.
Every panel now renders a normal Dockview chip tab: the singleTabMode "fullwidth" option is removed, because a lone tab stretched to full width reads as a title bar rather than a tab. Panel creation gains a createTabComponent dispatch alongside the existing createComponent one, and the Workshop tree panel gets a dedicated close-button-free tab renderer, so the tree can no longer be closed from its tab. The tree is also re-ensured at boot, so a stale persisted layout can never lose it, and a new Window > Workshop Panel menu item toggles it, sharing the Ctrl+B shortcut's command. File > New Chat is gone, along with AgentController.newChat: New Agent is now the only way to start a fresh conversation, collapsing two near-duplicate entry points into one. - The layout schema bumps from v2 to v3 and older snapshots are discarded: panels now serialize their tabComponent, and a v2 snapshot restored as-is would rebuild the Workshop tree without its protected tab renderer, resurrecting the closable tree. - createPanelTabComponent returns undefined for every panel other than the tree, which hands those panels to Dockview's default tab renderer rather than requiring a custom renderer per panel type.
The desktop app now builds with the cuda feature enabled by default, so a stock build is voice-capable out of the box at the cost of requiring the NVIDIA CUDA toolkit to compile; a machine without the toolkit builds with --no-default-features, and voice then stays off at runtime. The other half closes a waste path: voice is GPU-only, because a transcription take stalls on a CPU pass and the UI hides the mic. The server now checks gpu_transcription_available() before touching the whisper models. AppState::new skips the startup engine load and posts a "Voice disabled" status explaining that the models stay unloaded, and serve.rs hands the provisioning task an empty voice config so it exits immediately. A CPU build therefore never spends gigabytes downloading or loading models behind a mic that is not there, and never announces Voice ready. - The gate must exist in both boot paths: the startup engine load in AppState::new covers a model already on disk, while the provisioning task spawned in serve.rs is what would otherwise download a missing model through the gateway cache in the background. - The commit also carries cargo fmt fixups in drop_target.rs and chat_ws.rs - formatting churn only, no behavior change in either file.
Give the gateway reconnect loop a backoff discipline in the new backoff.rs: the delay escalates with jitter while the gateway is down, is shared across the heartbeat and relay paths, and resets only on useful work - a delivered streamed token or a successful buffered completion - never on a mere successful connect. A connect-without-delivery previously reset the schedule, so a gateway that accepted connections but never answered was hammered at the floor interval forever. A total-delay budget (24h) stops reconnect probes with an explicit give-up report on the heartbeat status instead of probing silently for the life of the process. Tests pin the schedule (escalation, jitter bounds, clamp, budget exhaustion, shared state across clones) and the reset semantics: mere connect keeps the backoff escalated, a delivered token and a buffered success reset it, an answered-but-declined exchange does not. - Reset flows through the delivery paths in chat_ws.rs and relay.rs, not the probe path, so the definition of useful work sits where delivery is proven.
Pin the traversal guarantee of the debug disk-serving asset path with three tests in assets.rs: a relative ../ escape, a Windows backslash-separator escape, and an absolute-path name that would make Path::join replace the asset root - each must answer 404. The targets are real files on disk (the crate's own manifest), so the debug path can only refuse on containment, never on a missing file, and the tests run in the debug profile, which is exactly the disk-serving path under scrutiny. No production change was needed: rust-embed 8.12.0's debug get() canonicalizes and prefix-checks against the asset root, matching the release embed-map miss, and the routes pass only fixed literal names, leaving ui_asset as the sole seam. - Tests-only commit by design; the step's deliverable was the proof, with a fix only if the proof failed.
Refactor the gateway runner from a blocking run(&ServeOptions) that owned the runtime and waited on Ctrl-C into a spawnable form: spawn(&ServeOptions) starts the server on a dedicated thread with its own multi-thread runtime, confirms readiness through an mpsc handshake before returning, and hands back a GatewayHandle exposing url(), shutdown(), and join(). Shutdown is a oneshot wired into the existing with_graceful_shutdown, and dropping the handle signals it too. run() is now a thin wrapper - spawn, install the Ctrl-C handler, join - so the binary's behavior is unchanged. Startup errors cross the handshake to the caller instead of dying on the spawned thread, and a bind conflict fails spawn with the bind error kind rather than reporting ready. The pattern mirrors the workshop server's serve.rs so the two embeddable servers read alike. - The watchdog/Termination machinery in serve.rs is deliberately not mirrored; gateway shutdown semantics stay as today until the hosting work needs more. - Ctrl-C waits on a small current-thread runtime on its own thread, preserving the existing handler-failure semantics.
Add workshop: Option<WorkshopConfig> to the gateway RawConfig, following the tools pattern under deny_unknown_fields: bind (default 127.0.0.1:7910), open_browser (default false), and optional [workshop.voice] and [workshop.tape] sub-tables mirroring the workshop server's own VoiceConfig and TapeConfig fields. There is deliberately no [workshop.gateway] section - the workshop client's base_url and api_key derive at boot from the gateway's own [server] values via ServerConfig::client_url(), which swaps an unspecified bind (0.0.0.0 or ::) for loopback, so credentials are never duplicated and cannot drift. The same api_key authorizes the admin endpoints, so the Model menu works over derived credentials with no extra config. A tape path that is absent or relative anchors against the directory holding the boot config, never the process cwd, keeping tape.jsonl and workshop-state.json together in a stable place. Like [server], the section is boot-only: a profile carrying a differing [workshop] is refused at boot by the existing match check, and a mid-run switch refusal rides the switch SSE stream's terminal error event without disturbing live state. - A one-sided [workshop] (present in only boot or only profile) is refused, mirroring the strict [server] value-equality rule. - AppState boot arguments are grouped into a BootOwned struct.
Behind a new workshop feature, the gateway spawns the workshop server on its own loopback listener in the same process. The Cargo surface: promptforge-ws-server becomes an optional dependency, workshop = [dep:promptforge-ws-server], and workshop-cuda forwards to the ws-server cuda feature; the default feature set stays empty, so headless gateway builds never pull whisper, CUDA, or the Node UI build into the graph. When the feature is compiled in and the boot config carries a [workshop] section, the spawn path builds the workshop Config programmatically in the new workshop.rs: the client URL derives from the gateway's own bound address (loopback-adjusted, with a port-zero bind swapped for the real bound port), the same api_key authorizes it, and the tape path anchors to the boot-config directory. The workshop spawns only after the gateway listener is bound; its ServerHandle rides inside GatewayHandle, and shutdown sequences workshop first, then gateway, so the workshop's final gateway calls never hit a dead socket. The workshop URL is logged, and open_browser opens a tab for headless-with-UI use. - A non-loopback workshop bind is refused at spawn. - /health and /v1/models exist on both routers; a comment marks the collision as the known blocker for a future nested path. - dep:open joins the workshop feature beyond the plan letter, to honor open_browser; it is gated, keeping the bare graph clean.
The shell no longer talks to a standalone workshop server: its dependency swaps to promptforge-gateway with the workshop feature enabled, and the shell default cuda feature forwards to workshop-cuda. Boot spawns the merged gateway in-process with profile default, waits on the hosted workshop /health through the existing health-wait, opens the window at the workshop URL, and shuts the handle down on window close. Discovery keeps its search-order shape but now looks for the gateway boot config: exe directory, cwd, ~/.promptforge/ gateway.toml. A first run generates the pair the gateway needs to boot - gateway.toml with a loopback [server] bind on 8081, a random hex api_key, and a [workshop] section carrying the current voice-model defaults, plus profiles/default.toml, since the gateway requires a profile and a profiles directory. Generated output is tested against the gateway crate's own profile resolution, so first-run files the gateway would refuse fail the suite. - The legacy workshop.toml flow and the standalone promptforge-ws-server binary stay untouched for development against an external gateway. - rand joins the shell deps for api_key generation.
The gateway README gains a Hosting the workshop section: the workshop and workshop-cuda feature flags with their build implications (Node/esbuild and whisper enter the gateway build only with --features workshop), field tables for [workshop], [workshop.voice], and [workshop.tape] including the boot-config- relative tape anchoring, the derived client credentials (no [workshop.gateway]; base_url and api_key derive from [server], loopback-adjusted), and the boot-only rule with the mid-run switch refusal riding the switch SSE terminal error event. This also completes the field reference the shell README points to. The design log records the five decisions of the merge: a second loopback listener now with nesting under /workshop/ as the documented future option (route collisions on /health and /v1/models are the known blocker), derived credentials over duplicated ones, the boot-only [workshop] section refused in profiles, workshop-first-then-gateway shutdown order, and the shell booting the merged gateway with first-run generation of gateway.toml and profiles/default.toml.
The serve tests wedge_http_connection fixture sent host: workshop, which the cross-site guard now refuses with an immediate 403 before ever polling the request body, so the connection drained gracefully and the grace-window and stopped-barrier tests failed: shutdown reported Graceful where the tests expected Forced. The guard behavior is the intended DNS-rebinding defense; the fixture was stale. It now sends a loopback host and declares JSON so the request passes the guard, reaches the body-consuming chat handler, and wedges the drain as the tests intend. Production shutdown code is unchanged.
The ratchet ledger lagged the recent hardening commits: four new modules had no entries, and seven modules grew past their recorded ceilings. Add the new entries at their measured sizes (atomic.rs 205, backoff.rs 229, cross_site.rs 338, deadline.rs 109) and raise the grown ceilings to actual line counts with no padding. Raise reasons, attributed per commit history: - app.rs 500->544: wiring for the cross-site guard, atomic writes, deadlines, and backoff. - assets.rs 27->66: the debug asset path-traversal proof. - chat_ws.rs 2851->2963: SSE decoder conformance and backoff reset hooks. - gateway.rs 1107->1300: gateway-call deadlines and decoder conformance tests. - heartbeat.rs 721->872: backoff-reset-on-useful-work rework and probe deadlines. - relay.rs 473->537: backoff reset on useful work. - workspace.rs 935->1018: cross-site guard and atomic writes.
Update what-promptforge-is.md to the product's current name: the Workbench is now the Workshop throughout - prose, section titles, and the references entry. No content changes beyond the rename.
Two review fixes to the atomic-write sweep. The sweep_orphaned_temps doc claimed every failure, including a missing directory, is logged and tolerated, but a NotFound directory is deliberately skipped without logging: the server has simply never written there. The doc now states that a missing directory is tolerated silently and reserves the logged-and-tolerated wording for the other failures (an unreadable directory or entry, an unremovable file). The sweep matched only the .pf-tmp suffix, so an orphaned workshop-state.json.tmp left by the pre-helper menu scheme was never removed. The sweep now also removes that fixed legacy name, covered by a test that plants the legacy orphan beside an intact state file and checks the sweep removes one and spares the other. - Ceiling: atomic.rs 205 -> 230; the legacy-name constant with its doc, the wider match, and the new test grew the module past its recorded ceiling, so the ceiling moves to the actual line count.
The test a_stalled_route_answers_408_at_its_deadline spent 50ms of wall clock waiting for the deadline layer to answer 408 over a handler asleep for 30s. It now runs under tokio's paused time: both the stall and the deadline advance virtually, so the test is instant and deterministic. A comment records the invisible constraint - paused time freezes real socket I/O, and the test stays safe only because the socketless oneshot does none. The named fix needed machinery the crate lacked: start_paused requires tokio's test-util feature, which promptforge-ws-server did not enable. It is now a dev-dependency feature, matching the pattern promptforge-mcp-server, promptforge-dev, and promptforge-core-tests already use. - Ceiling: deadline.rs 109 -> 112; the comment explaining why paused time is safe here grew the module past its recorded ceiling, so the ceiling moves to the actual line count.
Two review fixes to the reconnect backoff. The jitter draw computed xorshift(rng) mod (span + 1), where span falls back to u64::MAX when the nanos conversion overflows. Had that fallback ever fired, span + 1 would have wrapped and panicked in debug or wrapped in release; the draw now uses span.saturating_add(1), so the guarded arithmetic stays guarded end to end. The production xorshift64 in backoff.rs was duplicated byte-for-byte by a test-local copy inside gateway.rs's decoder test. The generator is now pub(crate) under backoff, the gateway test imports the shared one, and its doc notes the explicit-seed contract the gateway test relies on: each randomized failure names its seed. - Ceiling: backoff.rs 229 -> 232; the widened arithmetic comment and the shared-generator doc grew the module past its recorded ceiling, so the ceiling moves to the actual line count. gateway.rs shrank and keeps its ceiling.
The parity comment above the traversal tests said a debug build must refuse names resolving outside ui/dist/, without noting the limit of that guarantee: rust-embed 8.12.0 deliberately still serves an out-of-root symlink planted inside ui/dist/. A reader could take the comment as promising containment of the directory itself. One added sentence scopes the guarantee to request-supplied names and names the symlink bypass as outside it. No behavior changes; the tests are untouched. - Ceiling: assets.rs 66 -> 69; the added sentence grew the module past its recorded ceiling, so the ceiling moves to the actual line count.
spawn() wrapped two non-bind failures in StartupError::bind, whose Display reads "failed to bind the listener": a failure to spawn the gateway thread at all, and the thread exiting before the readiness handshake. Both error paths also ran let _ = thread.join(), discarding the panic payload of the one thread failure that closes the readiness channel with no message. Add StartupErrorKind::Thread (the kind enum is non_exhaustive) with a matching private representation, route the thread-spawn failure through it, and join the thread in both handshake-failure arms through a failed_handshake helper that downcasts the panic payload into the returned error text. - The panic payload must be read through &*payload: passing &payload unsize-coerces the Box itself into dyn Any, and the downcasts then see Box<dyn Any + Send> instead of the panic message. - A tokio runtime build failure inside serve_thread is still reported as Bind kind; that pre-existing misnaming is out of scope here.
The both-present arm of check_workshop_matches_boot reported only that "the profile's workshop settings differ", while the adjacent check_server_matches_boot names the exact differing field and its values. An operator whose profile drifted from the boot file had to diff the two [workshop] sections by hand. Compare the four fields in declaration order through a first_workshop_difference helper and name the first difference in the validation message: bind and open_browser print both values; voice and tape print both Debug forms (neither carries a secret). The message keeps the "[workshop] mismatch" prefix the existing tests assert on.
The no-workshop stub of spawn_if_configured suppressed clippy::unnecessary_wraps with #[allow], which stays silent forever. Switch to #[expect] with the same reason so the suppression warns once it goes stale - for example if the stub ever gains a fallible path. Verified against -D warnings in both feature configurations: the lint still fires in the no-workshop build (the expectation is fulfilled) and the workshop build compiles the hosted variant instead.
The open_browser honor called open::that on the workshop URL inline, so no test could observe it without opening a real browser. Split spawn_if_configured into a thin production wrapper (passing open::that) over a spawn_with_opener core that takes the opener as a plain closure - the rulebook's cheapest seam for a single varying operation. Three tests cover the honor: the opener is called with the workshop URL when open_browser is set, it never runs when the setting is absent, and a failing opener logs a warning without failing the spawn. Each spawns a real workshop on an ephemeral loopback port with the tape anchored in a tempdir, matching the runner's existing spawn fixtures.
GatewayHandle::shutdown stops a hosted workshop (waiting out its bounded drain) before sending the gateway's graceful-shutdown signal, so the workshop's final gateway calls never hit a dead socket. No test asserted that order, and the obvious stall-based test would rest on unverified hyper drain semantics and cost five-plus brittle wall-clock seconds. Add a sequence-recording seam instead: a test-only mpsc observer on GatewayHandle that records WorkshopStopped after the workshop's drain returns and GatewaySignaled after the shutdown send. The workshop test spawns with a [workshop] section, shuts down, and asserts the recorded order; both records are synchronous inside shutdown(), so the test has no timing dependence. A second test covers the no-workshop path (only GatewaySignaled), which also keeps the seam used in every test build. - The seam is cfg(test)-gated: the field, the ShutdownStep enum, the observe_shutdown setter, and the two record calls vanish from production builds.
The gateway's startup path called load_server and load_workshop on the
boot file back to back, and each ran its own collect_config_chain plus
${VAR} interpolation, so the same include tree was read, parsed, and
merged twice per boot.
promptforge-gateway-config gains load_boot_sections, which resolves the
chain and interpolates once, then extracts the [server] and optional
[workshop] sections from the same document. Section extraction now lives
in shared server_section / workshop_section helpers used by all three
loaders, so the single-section entry points keep their exact behavior:
load_server still requires [server], and load_workshop still returns
None for a missing section even when [server] is absent, which the
combined loader would reject.
load_startup in the gateway now makes one boot-file pass instead of
two, with the server and workshop parity checks unchanged. New tests
cover the combined loader: both sections extracted without full
validation, None for a missing [workshop], and a validation error
naming [server] when it is absent.
- The combined loader deliberately does not back load_workshop: a
workshop-only file has no [server], and the existing include-chain
test for that case pins the tolerant behavior.
The shell's workshop_url mapped the gateway handle's Option<&str> to the window URL inline, so the user-facing error arm - a boot config with no [workshop] section, leaving the shell with no page to open - had no test coverage. The mapping now lives in workshop_url_from, a pure Option<&str> -> anyhow::Result<String> function that workshop_url delegates to. Behavior is unchanged: the same context message, the same Ok passthrough. Two unit tests pin the contract: a present URL passes through untouched, and the None arm's error names the [workshop] section, so the message cannot drift into telling the user nothing actionable.
The shell's README sends readers to the gateway README for the field
reference, and the [workshop] tables landed, but the required [server]
section's bind and api_key were documented nowhere in it.
Adds a short The [server] section table between Usage and Hosting the
workshop: both fields required, both accepting ${VAR} interpolation,
with a pointer to the boot-ownership rule the two sections share.
serve_thread wrapped a tokio runtime build failure in StartupError::bind, whose Display reads "failed to bind the listener", misnaming what actually failed - the same class as the thread-spawn and pre-bind exit paths that StartupErrorKind::Thread was added to fix, and noted as left behind when that kind landed. The runtime build now reports through StartupError::thread, and the kind's doc widens to name the runtime build alongside the spawn, exit, and panic cases. Forcing a real runtime build failure takes resource exhaustion, so the path is covered by the existing kind-mapping test on the constructor rather than a dedicated spawn fixture. - Pre-existing bug predating the sweep, fixed in its own commit per the rulebook; the kind enum is non_exhaustive, so the widened doc is the only API-surface change.
failed_handshake's payload reader had three arms and a test for one: the panicked-thread fixture exercises the &str payload, but the owned String arm (a panic! with format args) and the non-string fallback (a panic_any call) were new behavior nothing would catch breaking. One unit test calls panic_message directly with each payload shape: borrowed &str, owned String, and a u64 standing in for a panic_any payload, pinning the "non-string panic payload" fallback text.
Author
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.
Brings the workshop desktop app fully up on macOS: build, local model provisioning, and voice transcription.
What was broken on macOS
libggml.dylib -> libggml.0.dylib -> libggml.0.17.0.dylib), sollama-servercould never install.navigator.mediaDeviceson plain-http origins (no loopback exemption, unlike WebView2) and from host apps without anNSMicrophoneUsageDescription, so the mic's feature check failed before any permission was ever asked.Changes
promptforge-ws/metal->promptforge-gateway/workshop-metal->promptforge-ws-server/metal->whisper-rs/metal, mirroring the CUDA chain. The runtime gate intranscribe.rsnow accepts either backend, with Metal treated as always present on macOS. macOS builds usecargo build -p promptforge-ws --no-default-features --features metal.media_capture.rsin the shell builds theWKWebViewConfigurationup front (WKWebView copies its configuration at init, so a post-build flip never reaches the page), clears WebKit's secure-connection capture gate through the same SPI behind Safari's "allow media capture on insecure sites" develop toggle (probed withrespondsToSelector:, degrading to voice-unavailable if WebKit drops it), and embeds an Info.plist withNSMicrophoneUsageDescriptionin the executable's__TEXT,__info_plistsection. The module opts out of the workspace unsafe ban the same wayfile_drop.rsdoes on Windows.Testing
cargo test -p promptforge-gateway --lib,cargo test -p promptforge-ws-server --features metal, andcargo test -p promptforge-ws --no-default-features --features metalall pass locally (556 tests), pluscargo fmt --checkand per-crate clippy with the metal feature set.CoreML as a possibly faster macOS voice backend is tracked separately in #6.