Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub fn bundled_manifests() -> Vec<Manifest> {
.expect("workflows manifest"),
serde_json::from_str(include_str!("../../../src/bundled/sessions/manifest.json"))
.expect("sessions manifest"),
serde_json::from_str(include_str!("../../../src/bundled/windows/manifest.json"))
.expect("windows manifest"),
]
}
fn is_bundled(id: &str) -> bool {
Expand Down Expand Up @@ -174,6 +176,10 @@ impl Manager {
std::env::var("BUZZODZ_SAFE_MODE").as_deref() == Ok("1"),
)
}
/// The profile directory; other profile-scoped desktop state lives beside the registry.
pub fn root(&self) -> &Path {
&self.root
}
fn lock(&self) -> Result<File> {
fs::create_dir_all(&self.root).map_err(err)?;
let file = OpenOptions::new()
Expand Down
200 changes: 200 additions & 0 deletions docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
---
title: Detachable plugin windows - Plan
date: 2026-09-14
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-brainstorm
status: phase 1 in progress
---

# Detachable plugin windows - Plan

## Goal capsule

**Objective.** On desktop, any contributed page (Messages, Projects, Agents,
Terminal, external plugins) can leave the main canvas into its own OS window,
and windows can hold any combination of pages, like browser tabs. Home stays in
the main window.

**Product authority.** Decisions below were made with the product owner on
2026-09-14. Open blockers: none; deferred checks are listed per phase.

## Product contract

### Decisions

- Desktop only (Tauri). Web keeps the single-window shell unchanged.
*(session-settled: user-directed — chosen over web `window.open` popups: no
drag between browser windows, and the live broker is desktop-only anyway.)*
- A window is a tab container. A page lives in exactly one window at a time;
detaching moves it, never duplicates it.
*(session-settled: user-directed — "any combination, just like a browser tab".)*
- Home and Settings stay in the main window. Detached windows carry the tab
strip and the macOS traffic-light inset only; community switcher, profile and
Settings are main-window chrome. Detached windows follow the main window's
selected community.
*(session-settled: user-approved — chosen over a full header per window to
keep one source of truth for community selection.)*
- Each window runs the full app (services, plugin runtime, relay session) and
renders only its assigned tabs. A central host window or shared worker is a
later refactor, funded only if measured cost warrants it.
*(session-settled: user-approved — chosen over a main-window RPC host: no
cross-webview serialization of the relay contract, plugin contract unchanged,
and the relay layer already coordinates instances through IndexedDB, Web Locks
and BroadcastChannel.)*
- Guard rails the owner asked for: nothing fires or publishes twice; no window
shows stale or missing data it could have had.
- Launcher panels (Bestie, Agent Activity) detach like pages under a
`panel:<key>` tab but always sit in a window's launcher row (right side);
a detached window without pages shows the panel full-size, and that view's
close returns it to main. *(revised 2026-09-18: user-directed — a panel
dropped on a detached window landed in the strip instead of the right.)*
Target-bound panels (GitHub, Profile,
Terminal drawer) stay with the page that opens them.
*(session-settled: user-directed 2026-09-14 — "should also be possible to
detach the bestie from everything".)*

### Requirements

1. Right-click (or context-menu key) on a page tab offers **Move to new
window**, **Move to main window** and **Move to Window N** for other open
windows. Home has no menu.
2. Moving a tab out of the window that shows it selects the next remaining tab
(main falls back to Home). A detached window whose last tab leaves closes.
3. Closing a detached window returns its tabs to the main window. Closing the
main window quits the app.
4. Disabling or removing a plugin removes its tab wherever it lives; a window
left without visible pages shows an explanation with **Close window**.
5. Window layout (which pages are in which window) is remembered per profile
and restored on launch. Positions/sizes: phase 3.
6. Dragging a tab out of the strip creates a window; dropping it on another
window's strip merges it there: phase 2.
7. Plugin authors see no new API for their pages. A moved page remounts,
exactly as it does on a revision change today.
8. The feature appears in Settings → Plugins as **Windows**
(`buzz.windows`). *(revised 2026-09-20: user-directed.)* The windowing
runtime stays host-owned; the plugin is the switch, through a new
`ctx.windows` capability (`enable()`, layout snapshot). Disabling returns
every tab to main and closes detached windows; a disabled plugin at launch
discards the saved layout instead of restoring it. Considered and rejected:
moving the tab strip, page filtering and chrome into plugin extension points
(three new host contracts with one consumer) and an external plugin (needs
native code and capabilities).

### Non-goals

- The same page open in two windows at once.
- Detaching target-bound companion panels (GitHub, Profile, Terminal drawer)
independently of the page that hosts them.
- Full shell header (community switcher, Settings) in detached windows.
- Web support.

### Acceptance examples

- Move Messages to a new window: the main tab strip loses Messages and shows
Home; the new window shows a strip with only Messages, follows the selected
community, reads and sends normally.
- Move Agents into that window: its strip shows Messages and Agents; main shows
neither.
- Close the detached window with the traffic light: Messages and Agents are
back in main.
- Relaunch: the same distribution of tabs comes back.
- A mention arrives while Messages is detached: exactly one native
notification, from the main window.
- Disable Projects in Settings while Projects is alone in a detached window:
within a second the window shows the empty explanation; **Close window**
works.

## Implementation plan

### Phase 1 — Windows, tabs and the move menu (this pass)

Rust owns the layout because it owns window lifecycle and the profile dir.

- `src-tauri/src/windows.rs`: `Layout { windows: [{ label, tabs: [pageKey] }] }`
persisted to `<profile>/windows.json`. Main is implicit: every page not
assigned to a detached window belongs to main. Commands `windows_layout` and
`windows_move_tab(page_key, destination)` where destination is `main`, `new`
or an existing label. Every change is persisted and emitted as `buzz:windows`
to all windows. Window `Destroyed` for a detached label returns its tabs to
main; `Destroyed` for `main` exits the app. Startup recreates saved windows.
Labels are `tabs-<n>`; page keys are validated against the contribution key
grammar (`plugin/page`).
- `crates/plugin-manager`: expose the profile directory (`Manager::root()`);
no behaviour change.
- `src-tauri/capabilities/default.json`: windows `main` and `tabs-*`; add event
listen/unlisten and window close.
- `src/features/windows/service.ts`: `WindowHost` with `label`, `isMain`,
`snapshot()/subscribe()` of the layout, `moveTab()`, `close()`, and the pure
`windowPages(layout, label, pages)` filter. Web fallback: main, all pages,
`moveTab` unavailable.
- `src/app/services.ts` (FOUNDATION): create the host; bind message
notifications only in main; in detached windows follow the main window's
saved community selection through `storage` events.
- `src/app/navigation.ts`: pages filtered by window; detached windows redirect
Home/Settings targets to their first tab; a selected tab that leaves the
window falls back to the next tab or Home.
- `src/app/shell/AppShell.tsx` + `TabMenu.tsx`: hide Home tab, community
switcher, launchers, page finder and profile outside main; per-tab context
menu with the move actions; empty-window notice with **Close window**.

Deferred checks for phase 1 (must run in the Tauri build, not in Vitest):
WKWebView shares IndexedDB, Web Locks and `BroadcastChannel` across windows of
the same app (read-state publisher serialization depends on it); Terminal
sessions owned by a page that moves windows end or are orphaned until exit —
confirm which and document; second-window cold start time and memory.

Known limitation to document: outbox records created in one window appear in
another window only after that window restarts. Because a page is in one
window at a time this does not surface in the UI today; it matters only if a
second page starts showing the shared outbox.

### Phase 2 — Drag-out and drag-merge (implemented 2026-09-14)

Pointer-event drag on tabs and launchers (`PageTab.tsx`): after a 6px
threshold a ghost follows the pointer; releasing inside this window's own header
cancels, anywhere else calls `windows_drop_tab(pageKey, screenX, screenY)`.
The ghost is a native always-on-top window (`drag-ghost`, reused across drags,
cursor events ignored, non-focusable) moved by `windows_drag_begin/move/end`;
a DOM ghost would vanish at the source window's edge. Its transparent rounded
shape needs Tauri's `macOSPrivateApi`, acceptable while bundling is off (App
Store distribution would need an opaque ghost instead). The frontend coalesces
moves to one IPC in flight. After any move, Rust focuses the destination window
and emits `buzz:windows:activate` to it so the moved tab becomes selected.
Rust hit-tests the logical screen point against every other Buzz window's outer
rectangle (`window_at`): a hit merges the tab into that window, otherwise a new
window opens with its strip under the pointer. HTML drag-and-drop is not used
because it cannot leave a webview. Keyboard/menu path unchanged. Not included:
reordering tabs within a strip (order stays host policy), z-order awareness when
windows overlap (first hit wins).

Deferred check: `screenX/Y` and Tauri `outer_position` agree on multi-display
setups and on non-macOS platforms.

### Phase 2b — Plugin switch (implemented 2026-09-20)

`src/bundled/windows` (`buzz.windows`) with `inject = ["windows"]` and
`ctx.effect(() => ctx.windows.enable())`. `WindowsService` in
`src/features/windows/service.ts` is the Cordis capability over the existing
`WindowHost`; the snapshot carries `enabled`, and `PageTab`/`PanelLaunchers`
offer menus and drag only while enabled. The enable disposer calls
`windows_reset` (Rust returns tabs and closes windows) unless the host itself is
being disposed, so app quit never resets the layout. `Windows::open` takes the
plugin's enabled flag from the profile catalog (`Manager::catalog`) and discards
a saved layout while disabled. Deferred checks (desktop build): toggle off with
two detached windows open; relaunch with the plugin disabled; re-enable and
detach again; verify each detached window's own plugin runtime disables cleanly
(its reset is a no-op after main's).

### Phase 3 — Restore positions and sizes

Persist window bounds in `windows.json`; restore with on-screen clamping.

### Validation

- Vitest: `windowPages`, web fallback, layout reducer, navigation fallback.
- Rust: layout moves, closed-window return, persistence round trip, invalid
input rejection.
- Manual (`bin/just desktop`): the acceptance examples above, plus the
deferred checks. `just scan` before integration; browser journeys are not
affected by desktop-only code paths but must stay green.
20 changes: 20 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,26 @@ material belongs to the draft, not the optional tool. Add these contracts agains
real workflows rather than declaring the toolbar a universal editor API.


## Desktop tab windows

Detaching tabs into their own windows is a host capability
([shell design](shell-design.md#detached-tab-windows-desktop)); the bundled
**Windows** plugin (`buzz.windows`) is the switch that turns it on,
so it appears in Settings → Plugins like any other bundled plugin. Its whole
implementation is `ctx.effect(() => ctx.windows.enable())`: activation enables
detaching for the window it runs in, and disposal (disable in Settings) switches
it off and gathers every tab back into the main window. App teardown never
resets the layout. On desktop launch, Rust reads the same plugin setting from
the profile and skips restoring saved windows while the plugin is disabled.

Plugins declaring `inject = ["windows"]` receive `ctx.windows`: `label`,
`isMain`, `snapshot()`/`subscribe()` of the window layout (`enabled`, which
tabs live in which detached window, the tab hovering as a drop target) and
`enable()`. Every window runs a full plugin runtime, so a plugin instance sees
the window it is rendered in. The type-only `@buzz/author` exports `Windows`,
`WindowLayout` and `WindowSnapshot`. Moving tabs from plugin code is not part
of this contract yet; the host's tab strip owns that interaction.

## In-app keyboard shortcuts

The host composes one `ShortcutsService` in `app/services.ts`. Plugins declare
Expand Down
37 changes: 33 additions & 4 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,42 @@ inset or imitation window controls. Other
platforms retain their native decorations. Drag regions are limited to the
header background; controls remain clickable. On macOS, double-clicking that
background follows the current system title-bar preference (Fill/Zoom, Minimize,
or no action); changing the preference does not require restarting Buzz. Other
platforms retain Tauri's native drag-region behavior. The main-window capability
grants only titlebar dragging and the internal maximize action used by that
handler, plus scoped HTTP(S) opening for
or no action) in every Buzz window; changing the preference does not require
restarting Buzz. Other platforms retain Tauri's native drag-region behavior. The
window capability (`main` and detached `tabs-*` windows) grants only titlebar
dragging, the internal maximize action used by that handler, layout event
listening, closing a window, plus scoped HTTP(S) opening for
[external links](channels.md#run-the-integration). See
[Tauri window customization](https://v2.tauri.app/learn/window-customization/).

### Detached tab windows (desktop)

The bundled **Windows** plugin (`buzz.windows`, Settings → Plugins)
switches this on; off, tabs stay in main and any detached windows return their
tabs and close. See [desktop tab windows](plugin-architecture.md#desktop-tab-windows)
for the `ctx.windows` capability. Drag a page tab or a launcher (Bestie, Agent
Activity) out of the strip to open
it in a new window at the drop point, or onto another Buzz window to merge it
there; releasing back in the strip cancels. While dragging, a native
always-on-top pill (`drag-ghost`, `public/drag-ghost.html`, transparent via
`macOSPrivateApi`) follows the pointer across the whole screen; it ignores the
cursor and never takes focus. The destination window is focused and the moved
tab becomes its selected tab. Right-click for **Move to new
window**, **Move to main window** or **Move to Window N**. Launcher panels
(`panel:<key>`) live in the launcher row on the right of whichever window holds
them and open as the companion card there; a detached window without pages shows
its panel full-size, and that view's close returns the panel to main. A page
lives in exactly one window at a time; Home and Settings stay in `main`, so
detached windows carry only the navigation arrows, the tab strip and their
launchers, and follow the community selected in `main`. Rust owns the layout
(`src-tauri/src/windows.rs`, persisted per profile in `windows.json`) and creates,
restores and closes the windows; each webview runs the full app and shows the
pages assigned to its label (`src/features/windows/`). Closing a detached window
returns its tabs to `main`; closing `main` quits. Only `main` raises desktop
notifications. See the
[plan](plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md) for the
deferred checks and later phases (drag-out, window bounds).

The top-right group contains enabled plugin launchers (Bestie supplies the snake),
a page finder, and the local avatar. `ProfileButton.tsx` subscribes to the community
service's local default profile and opens an anchored account dropdown containing
Expand Down
51 changes: 51 additions & 0 deletions public/drag-ghost.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dragging tab</title>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: transparent;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
/* Same pad as GHOST_PAD in src-tauri/src/windows.rs: room for the shadow. */
body {
padding: 16px;
box-sizing: border-box;
}
/* Everything else (size, colours, font, spacing) comes from the lifted tab. */
#tab {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
#tab > :is(svg, img) {
flex: none;
}
#tab > img {
object-fit: contain;
}
#title {
overflow: hidden;
text-overflow: ellipsis;
}
#title:empty {
display: none;
}
</style>
</head>
<body>
<div id="tab"><span id="title"></span></div>
</body>
<script src="/drag-ghost.js"></script>
</html>
Loading
Loading