8.3 KiB
Executable File
ADR-003: Workspace Tab (Peek-Style Orchestration)
Date: 2026-07-07 Status: Accepted
Context
The v3.0 workflow for interacting with a running tool required context-switching out of the AI-LSC app:
- Web-interface tools (OpenWebUI, Hermes, Odysseus, ComfyUI, etc.) required opening a browser tab and navigating to
http://127.0.0.1:{port}. - CLI tools (Aider, Claude Code, OpenHands, etc.) required opening a terminal and
tmux attach -t ai_lsc_<uid>::<tool_id>.
When debugging a stack of 8+ tools, this meant 8+ browser tabs + 8+ terminal windows — exactly the juggling the app was supposed to eliminate.
The user asked for a "peek orchestration" surface that "feels like managing VMs from virt-manager or aqemu" — every staged tool reachable from a single window, no context-switch to a browser or terminal app required. The user also raised the option of using servo (Mozilla's Rust web engine) to embed web tools directly, calling it a "workspace" that "can have multiple tabs for various tools like openwebui, hermes dashboard, or odysseus even."
Decision
Add a new WorkspaceTab widget with one sub-tab per active tool. The sub-tab type is chosen by the tool's feature flags:
has_web=True→_WebToolPage— embeds the tool's web UI viaQWebEngineViewhas_cli=True(and no web) →_CliToolPage— embeds the tool's tmux session viatmux capture-panepolling- otherwise →
_PlaceholderPage— explains the tool is passive / library
Web embedding: QWebEngineView (with servo-swap path documented)
The web embedding uses PySide6.QtWebEngineWidgets.QWebEngineView by default. Servo's Python bindings are not yet production-ready and have no first-class PySide6 integration, so servo is not the default. However, the module is structured so swapping in servo later is a one-line change: replace _make_web_view() with a servo-backed widget that exposes the same setUrl() / url() / load() API. The rest of _WebToolPage only depends on that API.
The _WebToolPage includes:
- A URL bar at the top showing the loaded URL (monospace, slate-50 background)
- A ⟳ reload button
- A ↗ "open in external browser" button (fallback for when QtWebEngine is not installed)
- The
QWebEngineViewitself, stretched to fill the remaining space
When QtWebEngine is not installed (some minimal PySide6 installs skip it), the page falls back to a _PlaceholderPage explaining the situation and offering the ↗ button to open the URL externally.
CLI embedding: tmux capture-pane polling
The _CliToolPage is a QTextEdit (read-only, dark theme, monospace font) that polls tmux capture-pane -t <session>::<tool_id> -p -S -200 every 250 ms (4 Hz) and renders the captured output. The poll is driven by a QTimer; the captured content is compared to the current toPlainText() and only written if it changed (avoids flicker). The cursor is auto-scrolled to the bottom on each refresh.
The terminal is read-only — input is not supported. This is a deliberate scope limit: building a full PTY emulator is a separate project, and the user's stated use case ("peek orchestration") is read-only monitoring. For interactive input, the user should use a real terminal app and tmux attach -t <session>::<tool_id>.
The page exposes a stop_polling() method that the parent WorkspaceTab calls when the sub-tab is closed — this prevents the QTimer from outliving the page widget.
Placeholder for passive / library tools
The _PlaceholderPage is shown for tools that have no interactive surface (no web + no CLI). It explains that the tool "is a passive/library tool with no interactive surface. It runs in the background and is consumed by other tools." This is informational only — there is no Start button because the tool's launcher type is passive (no systemd / tmux / desktop / lxc).
Placeholder for not-yet-running tools
When a web or CLI tool is staged but not running, the sub-tab shows a _PlaceholderPage with a Start tool button. The button emits start_tool_requested(str), which the main window connects to a handler that finds the matching ServiceRow and calls start_service(). After 1.5 s the workspace tab auto-refreshes so the placeholder → live view switch happens automatically.
Sub-tab labels
Each sub-tab is labeled with an emoji prefix + tool_id:
- 🌐 — web-interface tool
- ⌨ — CLI tool
- 📦 — passive / library tool
- ⏸ suffix — tool is staged but not yet running
The emoji encoding lets the user scan the tab bar at a glance and see what kind of surface each sub-tab provides.
Sub-tab closing
Sub-tabs are closable via the standard × button. Closing a sub-tab calls stop_polling() on the page (for CLI tools) but does NOT stop the underlying tool — that's the user's call from the Stack Editor. The empty-state placeholder is restored when all sub-tabs are closed.
Refresh model
WorkspaceTab.refresh() is called from:
_populate_services(after the Stack Editor recompiles the active set)- Nav-click on the Workspace entry in the sidebar
- 1.5 s after a "Start tool" click (so the placeholder → live view switch happens)
The refresh is destructive — all sub-tabs are torn down and rebuilt. This is simpler than diffing the active set, and the user's mental model is "refresh = rebuild." A future improvement would be to preserve sub-tab order and only add/remove the delta.
Consequences
Positive
- Every active tool is reachable from a single window — no context-switch to a browser or terminal app.
- Web tools embed directly via QWebEngineView, which is the standard PySide6 solution and ships with most PySide6 installs.
- CLI tools attach to the existing tmux session that the runtime executor already manages — no new process lifecycle to worry about.
- The "Start tool" button on placeholder pages bridges the gap between staging and running without requiring the user to switch to the Stack Editor.
- The servo-swap path is documented so a future servo migration is a one-line change.
Negative
- The CLI embedding is read-only. Users who want interactive input must still use a real terminal app. This is a deliberate scope limit, not a bug.
- The
tmux capture-panepoll runs every 250 ms per CLI sub-tab. With 10 CLI tools open, that's 40 Hz of subprocess calls — measurable but not heavy. A future improvement would be to pause polling when the sub-tab is not visible. - The web embedding depends on
QtWebEngine, which is a separate package on some Linux distros. The fallback placeholder handles the missing-package case gracefully. - The destructive refresh model means sub-tab order is not preserved across refreshes. A future improvement would be to preserve order.
Neutral
- The emoji prefixes (🌐 ⌨ 📦 ⏸) are not accessible to screen readers. A future improvement would be to add
setAccessibleNameon each sub-tab.
Alternatives considered
Open tools in an external browser / terminal
Rejected — this is the v3.0 behavior the user explicitly asked to replace. The whole point of the Workspace tab is to eliminate the context-switch.
Use servo instead of QWebEngineView
Rejected as the default because servo's Python bindings are not production-ready and have no first-class PySide6 integration. The module is structured so servo can be swapped in later as a one-line change to _make_web_view().
Build a full PTY emulator for CLI tools
Rejected as scope creep. The user's stated use case is "peek orchestration" — read-only monitoring. A full PTY emulator is a separate project. For interactive input, the user should tmux attach from a real terminal.
One workspace per tool (separate windows)
Rejected — this would re-create the multi-window juggling the app is supposed to eliminate. The single-window, multi-tab model matches the virt-manager / aqemu reference the user cited.
Future work
- Preserve sub-tab order across refreshes.
- Pause
tmux capture-panepolling when the sub-tab is not visible. - Add
setAccessibleNameto each sub-tab for screen-reader support. - Add a "Detach" button on each sub-tab that opens the tool in an external browser / terminal (for when the user does want a separate window).
- Swap in servo for web embedding once servo's Python bindings are production-ready.
- Add interactive input to the CLI embedding (would require a real PTY, e.g. via
QProcess+QTerminalor a third-party widget).