ai-lsc/docs/ADR-003-workspace-tab.md

8.3 KiB
Executable File
Raw Permalink Blame History

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 via QWebEngineView
  • has_cli=True (and no web) → _CliToolPage — embeds the tool's tmux session via tmux capture-pane polling
  • 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 QWebEngineView itself, 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:

  1. _populate_services (after the Stack Editor recompiles the active set)
  2. Nav-click on the Workspace entry in the sidebar
  3. 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-pane poll 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 setAccessibleName on 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-pane polling when the sub-tab is not visible.
  • Add setAccessibleName to 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 + QTerminal or a third-party widget).