diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index e730130ac..ad64c7047 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -75,6 +75,8 @@ pub fn bundled_manifests() -> Vec { .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 { @@ -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 { fs::create_dir_all(&self.root).map_err(err)?; let file = OpenOptions::new() diff --git a/docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md b/docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md new file mode 100644 index 000000000..084288732 --- /dev/null +++ b/docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md @@ -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:` 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 `/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-`; 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. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b9c83fc2f..7c423f84c 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 diff --git a/docs/shell-design.md b/docs/shell-design.md index 2519e8ee3..f90da391a 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -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:`) 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 diff --git a/public/drag-ghost.html b/public/drag-ghost.html new file mode 100644 index 000000000..004f22088 --- /dev/null +++ b/public/drag-ghost.html @@ -0,0 +1,51 @@ + + + + + Dragging tab + + + +
+ + + diff --git a/public/drag-ghost.js b/public/drag-ghost.js new file mode 100644 index 000000000..78b9f1aa1 --- /dev/null +++ b/public/drag-ghost.js @@ -0,0 +1,53 @@ +// Native drag ghost: an always-on-top window Rust moves under the pointer while +// a tab is dragged. It renders the lifted tab from a spec the app window read +// off its live DOM (size, icon markup, resolved styles), so it matches the tab +// and the current theme exactly. The spec arrives in the query string on +// creation and via hashchange on reuse; this page needs no IPC. +const render = () => { + const raw = + new URLSearchParams(location.hash.slice(1)).get("spec") || + new URLSearchParams(location.search).get("spec"); + let spec; + try { + spec = raw ? JSON.parse(raw) : undefined; + } catch { + spec = undefined; + } + if (!spec || typeof spec !== "object") return; + const text = (value) => (typeof value === "string" ? value : ""); + const tab = document.getElementById("tab"); + tab.style.width = `${Number(spec.width) || 0}px`; + tab.style.height = `${Number(spec.height) || 0}px`; + tab.style.background = text(spec.background); + tab.style.color = text(spec.color); + tab.style.font = text(spec.font); + tab.style.padding = text(spec.padding); + tab.style.gap = text(spec.gap); + tab.style.borderRadius = text(spec.radius); + const rims = text(spec.shadow); + const lift = "0 6px 20px rgba(0, 0, 0, 0.28)"; + tab.style.boxShadow = rims && rims !== "none" ? `${rims}, ${lift}` : lift; + for (const old of tab.querySelectorAll("svg, img")) old.remove(); + const icon = text(spec.icon); + // Only the tab's own icon element is accepted: an or a same-origin . + if (/^<(svg|img)[\s>]/.test(icon)) { + const parsed = new DOMParser().parseFromString(icon, "text/html").body + .firstElementChild; + if ( + parsed && + (parsed.tagName === "svg" || + (parsed.tagName === "IMG" && + parsed.getAttribute("src")?.startsWith("/"))) + ) { + const size = text(spec.iconSize); + if (size) { + parsed.style.width = size; + parsed.style.height = size; + } + tab.prepend(parsed); + } + } + document.getElementById("title").textContent = text(spec.title); +}; +window.addEventListener("hashchange", render); +render(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index caeafbcc9..3000cc0c6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,7 +14,8 @@ url = "2" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +# macos-private-api: transparent webview for the tab drag ghost (bundling is off). +tauri = { version = "2", features = ["macos-private-api"] } buzzodz-plugins = { path = "../crates/plugin-manager" } buzz-agent-controller = { path = "../crates/agent-controller" } serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 868e796cc..4dce053ab 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,11 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-window", - "description": "Allow the main window title bar controls and external HTTP(S) links.", - "windows": ["main"], + "description": "Allow title bar controls, layout events, closing detached tab windows and external HTTP(S) links.", + "windows": ["main", "tabs-*"], "permissions": [ "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize", + "core:window:allow-close", + "core:event:allow-listen", + "core:event:allow-unlisten", { "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7eeb406a8..43313342d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod agents; mod dock; mod notifications; mod terminal; +mod windows; use agent_models::{agent_models_begin, agent_models_cancel, agent_models_run, ModelHost}; use agents::{ agent_control_action, agent_control_create_commit, agent_control_create_prepare, @@ -24,6 +25,10 @@ use terminal::{ terminal_close, terminal_close_owner, terminal_create_owner, terminal_read, terminal_resize, terminal_spawn, terminal_write, Terminals, }; +use windows::{ + windows_drag_begin, windows_drag_end, windows_drag_move, windows_drop_tab, windows_layout, + windows_move_tab, windows_reset, Windows, +}; #[derive(Clone, Default)] struct Imports(Arc>>); @@ -342,11 +347,19 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen terminal_write, terminal_resize, terminal_close, - terminal_close_owner + terminal_close_owner, + windows_layout, + windows_move_tab, + windows_drop_tab, + windows_drag_begin, + windows_drag_move, + windows_drag_end, + windows_reset ] } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let manager = Manager::from_env(); let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) @@ -382,6 +395,7 @@ pub fn run() { .map(|root| root.join("agent-runtime")) .map_err(|_| "Could not resolve app runtime resources".to_owned()); app.manage(AgentHost::initialize(paths, resources)); + windows::restore(app.handle()); Ok(()) }); #[cfg(target_os = "macos")] @@ -390,7 +404,17 @@ pub fn run() { .manage(Imports::default()) .manage(Terminals::default()) .manage(Notifications::default()) - .manage(PluginManager(Manager::from_env())) + .manage(Windows::open( + manager.as_ref().ok().map(Manager::root), + windows::plugin_enabled(manager.as_ref().ok()), + )) + .manage(PluginManager(manager)) + .on_window_event(|window, event| { + // Only user/explicit closes change the layout; app exit keeps it for restore. + if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + windows::window_closing(window.app_handle(), window.label()); + } + }) .invoke_handler(commands()) .build(tauri::generate_context!()) .expect("failed to build Buzz Foundation") diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs new file mode 100644 index 000000000..a89006291 --- /dev/null +++ b/src-tauri/src/windows.rs @@ -0,0 +1,738 @@ +//! Detached tab windows. Rust owns which pages live in which window because it +//! owns window lifecycle and the profile directory. Every webview runs the full +//! app and renders only the tabs assigned to its label; pages assigned to no +//! detached window belong to `main`, which also keeps Home and Settings. +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, +}; +use tauri::{Emitter as _, Manager as _, Runtime}; + +pub(crate) const MAIN: &str = "main"; +/// The bundled plugin that switches detaching on; see `src/bundled/windows`. +const PLUGIN_ID: &str = "buzz.windows"; +const EVENT: &str = "buzz:windows"; +/// Sent to the destination window only, after the layout, naming the moved tab. +const ACTIVATE: &str = "buzz:windows:activate"; +/// Sent to one window with `true` when a dragged tab hovers it and `false` when it leaves. +const DROP_TARGET: &str = "buzz:windows:drop-target"; +const FILE: &str = "windows.json"; +const MAX_WINDOWS: usize = 16; +const MAX_TABS: usize = 64; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TabWindow { + pub label: String, + pub tabs: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Layout { + #[serde(default)] + pub windows: Vec, +} + +/// Windows to create and close after a layout change is persisted. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Change { + pub created: Option, + pub closed: Vec, +} + +fn valid_segment(segment: &str) -> bool { + !segment.is_empty() + && segment.len() <= 80 + && segment.as_bytes()[0].is_ascii_alphanumeric() + && segment.bytes().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'.' || c == b'_' || c == b'-' + }) +} + +/// Tabs are page contribution keys `/` or launcher panels +/// `panel:/`; Home and Settings are neither. +fn valid_page_key(key: &str) -> Result<(), String> { + let key = key.strip_prefix("panel:").unwrap_or(key); + match key.split_once('/') { + Some((plugin, page)) if valid_segment(plugin) && valid_segment(page) => Ok(()), + _ => Err("Invalid page key".into()), + } +} + +fn valid_label(label: &str) -> bool { + label + .strip_prefix("tabs-") + .is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.bytes().all(|c| c.is_ascii_digit())) +} + +impl Layout { + fn sanitize(mut self) -> Self { + self.windows.retain(|w| { + valid_label(&w.label) + && !w.tabs.is_empty() + && w.tabs.len() <= MAX_TABS + && w.tabs.iter().all(|t| valid_page_key(t).is_ok()) + }); + self.windows.truncate(MAX_WINDOWS); + let mut seen = std::collections::HashSet::new(); + for window in &mut self.windows { + window.tabs.retain(|tab| seen.insert(tab.clone())); + } + self.windows.retain(|w| !w.tabs.is_empty()); + self + } + fn next_label(&self) -> String { + let next = self + .windows + .iter() + .filter_map(|w| w.label.strip_prefix("tabs-")?.parse::().ok()) + .max() + .map_or(1, |n| n + 1); + format!("tabs-{next}") + } + fn detach(&mut self, page: &str) { + for window in &mut self.windows { + window.tabs.retain(|tab| tab != page); + } + } + fn prune(&mut self) -> Vec { + let closed = self + .windows + .iter() + .filter(|w| w.tabs.is_empty()) + .map(|w| w.label.clone()) + .collect(); + self.windows.retain(|w| !w.tabs.is_empty()); + closed + } + /// Move one page to `main`, `new`, or an open detached window. + pub(crate) fn move_tab(&mut self, page: &str, destination: &str) -> Result { + valid_page_key(page)?; + let mut change = Change::default(); + match destination { + MAIN => self.detach(page), + "new" => { + if self.windows.iter().any(|w| w.tabs == [page]) { + return Ok(change); // Already alone in its own window. + } + if self.windows.len() >= MAX_WINDOWS { + return Err("Too many windows are open".into()); + } + self.detach(page); + let label = self.next_label(); + self.windows.push(TabWindow { + label: label.clone(), + tabs: vec![page.to_owned()], + }); + change.created = Some(label); + } + label => { + let index = self + .windows + .iter() + .position(|w| w.label == label) + .ok_or("That window is no longer open")?; + if self.windows[index].tabs.iter().any(|t| t == page) { + return Ok(change); + } + if self.windows[index].tabs.len() >= MAX_TABS { + return Err("That window has too many tabs".into()); + } + self.detach(page); + self.windows[index].tabs.push(page.to_owned()); + } + } + change.closed = self.prune(); + Ok(change) + } + /// Every tab back to `main`; returns the labels of the windows to close. + pub(crate) fn reset(&mut self) -> Vec { + self.windows.drain(..).map(|w| w.label).collect() + } + /// A closed detached window returns its tabs to `main`. + pub(crate) fn remove_window(&mut self, label: &str) -> bool { + let before = self.windows.len(); + self.windows.retain(|w| w.label != label); + self.windows.len() != before + } +} + +pub(crate) struct Windows { + path: Option, + layout: Mutex, + /// The window currently under a dragged tab, told to show itself as the drop target. + drop_target: Mutex>, + /// The tab key being dragged, so a hovered window knows where it would land. + dragging: Mutex>, +} + +/// Payload of `DROP_TARGET`: the dragged tab, or `None` when the drag leaves. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DropTarget<'a> { + tab: Option<&'a str>, +} + +impl Windows { + /// Without a profile directory the layout lives only for this process. + /// With the `buzz.windows` plugin disabled, a saved layout is discarded so + /// no detached window is restored only to be closed again. + pub(crate) fn open(profile: Option<&Path>, enabled: bool) -> Self { + let path = profile.map(|root| root.join(FILE)); + let saved = path + .as_deref() + .and_then(|path| fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default() + .sanitize(); + let windows = Self { + path, + layout: Mutex::new(Layout::default()), + drop_target: Mutex::new(None), + dragging: Mutex::new(None), + }; + if enabled { + if let Ok(mut layout) = windows.layout.lock() { + *layout = saved; + } + } else if saved != Layout::default() { + if let Err(error) = windows.save(&Layout::default()) { + eprintln!("Window layout save failed: {error}"); + } + } + windows + } + fn set_dragging(&self, tab: Option) { + if let Ok(mut current) = self.dragging.lock() { + *current = tab; + } + } + /// Point the drop-target highlight at `next`; only changes are broadcast. + fn set_drop_target(&self, app: &tauri::AppHandle, next: Option) { + let Ok(mut current) = self.drop_target.lock() else { + return; + }; + if *current == next { + return; + } + if let Some(previous) = current.take() { + let _ = app.emit_to(&previous, DROP_TARGET, DropTarget { tab: None }); + } + if let Some(label) = &next { + let dragging = self.dragging.lock().ok().and_then(|d| d.clone()); + let _ = app.emit_to( + label, + DROP_TARGET, + DropTarget { + tab: dragging.as_deref(), + }, + ); + } + *current = next; + } + pub(crate) fn layout(&self) -> Layout { + self.layout.lock().map(|l| l.clone()).unwrap_or_default() + } + fn save(&self, layout: &Layout) -> Result<(), String> { + let Some(path) = &self.path else { + return Ok(()); + }; + let parent = path.parent().ok_or("Invalid layout path")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let bytes = serde_json::to_vec_pretty(layout).map_err(|e| e.to_string())?; + let temp = parent.join(format!("{FILE}.{}.tmp", std::process::id())); + fs::write(&temp, bytes).map_err(|e| e.to_string())?; + fs::rename(&temp, path).map_err(|e| e.to_string()) + } + fn update( + &self, + operation: impl FnOnce(&mut Layout) -> Result, + ) -> Result<(T, Layout), String> { + let mut guard = self + .layout + .lock() + .map_err(|_| "Window layout is unavailable")?; + let mut next = guard.clone(); + let result = operation(&mut next)?; + if next != *guard { + self.save(&next)?; + *guard = next.clone(); + } + Ok((result, next)) + } +} + +fn create( + app: &tauri::AppHandle, + label: &str, + at: Option<(f64, f64)>, +) -> tauri::Result<()> { + let builder = + tauri::WebviewWindowBuilder::new(app, label, tauri::WebviewUrl::App("index.html".into())) + .title("Buzz Foundation") + .inner_size(1000.0, 720.0) + .min_inner_size(480.0, 400.0); + // A dropped tab opens with the tab strip under the pointer, like a browser. + let builder = match at { + Some((x, y)) => builder.position((x - 160.0).max(0.0), (y - 28.0).max(0.0)), + None => builder, + }; + #[cfg(target_os = "macos")] + let builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .traffic_light_position(tauri::LogicalPosition::new(20.0, 26.0)); + builder.build().map(|_| ()) +} + +fn publish(app: &tauri::AppHandle, layout: &Layout) { + if let Err(error) = app.emit(EVENT, layout) { + eprintln!("Window layout broadcast failed: {error}"); + } +} + +/// Recreate saved detached windows. Entries whose window cannot open return to main. +pub(crate) fn restore(app: &tauri::AppHandle) { + let state = app.state::(); + let saved = state.layout(); + let mut failed = Vec::new(); + for window in &saved.windows { + if let Err(error) = create(app, &window.label, None) { + eprintln!("Could not restore window {}: {error}", window.label); + failed.push(window.label.clone()); + } + } + if failed.is_empty() { + return; + } + match state.update(|layout| { + for label in &failed { + layout.remove_window(label); + } + Ok(()) + }) { + Ok((_, layout)) => publish(app, &layout), + Err(error) => eprintln!("Window layout save failed: {error}"), + } +} + +/// Closing main quits the whole app; closing a detached window returns its tabs. +pub(crate) fn window_closing(app: &tauri::AppHandle, label: &str) { + if label == MAIN { + app.exit(0); + return; + } + let state = app.state::(); + match state.update(|layout| Ok(layout.remove_window(label))) { + Ok((true, layout)) => publish(app, &layout), + Ok((false, _)) => {} + Err(error) => eprintln!("Window layout save failed: {error}"), + } +} + +#[tauri::command] +pub(crate) fn windows_layout(state: tauri::State<'_, Windows>) -> Layout { + state.layout() +} + +/// The `buzz.windows` plugin was switched off: gather every tab into main. +#[tauri::command] +pub(crate) async fn windows_reset( + app: tauri::AppHandle, + state: tauri::State<'_, Windows>, +) -> Result { + let (closed, layout) = state.update(|layout| Ok(layout.reset()))?; + for label in &closed { + if let Some(window) = app.get_webview_window(label) { + let _ = window.close(); + } + } + if !closed.is_empty() { + publish(&app, &layout); + } + Ok(layout) +} + +/// Whether the desktop should detach tabs at all, per the profile's plugin settings. +pub(crate) fn plugin_enabled(manager: Option<&buzzodz_plugins::Manager>) -> bool { + let Some(manager) = manager else { return true }; + match manager.catalog() { + Ok(catalog) => catalog + .plugins + .iter() + .find(|plugin| plugin.manifest.id == PLUGIN_ID) + .map_or(true, |plugin| plugin.enabled), + Err(_) => true, + } +} + +fn apply_move( + app: &tauri::AppHandle, + state: &Windows, + page_key: &str, + destination: &str, + at: Option<(f64, f64)>, +) -> Result { + let (change, layout) = state.update(|layout| layout.move_tab(page_key, destination))?; + if let Some(label) = &change.created { + if let Err(error) = create(app, label, at) { + let (_, layout) = state.update(|layout| { + layout.remove_window(label); + Ok(()) + })?; + publish(app, &layout); + return Err(format!("Could not open a window: {error}")); + } + } + for label in &change.closed { + if let Some(window) = app.get_webview_window(label) { + let _ = window.close(); + } + } + publish(app, &layout); + // Like a browser, the moved tab becomes the active tab of a focused window. + let target = change.created.as_deref().unwrap_or(destination); + if let Some(window) = app.get_webview_window(target) { + let _ = window.set_focus(); + if let Err(error) = app.emit_to(target, ACTIVATE, page_key) { + eprintln!("Tab activation failed: {error}"); + } + } + Ok(layout) +} + +#[tauri::command] +pub(crate) async fn windows_move_tab( + app: tauri::AppHandle, + state: tauri::State<'_, Windows>, + page_key: String, + destination: String, +) -> Result { + apply_move(&app, &state, &page_key, &destination, None) +} + +/// The pill that follows the pointer while a tab is dragged. A DOM ghost would +/// vanish at the source window's edge; this always-on-top window is reused +/// across drags and never receives cursor events or focus. +const GHOST: &str = "drag-ghost"; +/// Transparent margin around the pill for its shadow; `public/drag-ghost.html` pads the same. +const GHOST_PAD: f64 = 16.0; +const GHOST_SPEC_MAX: usize = 16 * 1024; + +/// The lifted tab's geometry; the rest of the spec is passed through to the page. +#[derive(Deserialize)] +struct GhostSize { + width: f64, + height: f64, +} + +fn ghost( + app: &tauri::AppHandle, + spec: &str, + size: &GhostSize, +) -> tauri::Result> { + let outer = + tauri::LogicalSize::new(size.width + 2.0 * GHOST_PAD, size.height + 2.0 * GHOST_PAD); + if let Some(window) = app.get_webview_window(GHOST) { + // The nonce makes every show a hashchange, so the page re-renders even + // when the same tab is dragged again. + static SHOWN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let nonce = SHOWN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + window.eval(format!( + "location.hash = {}", + serde_json::to_string(&format!("#spec={}&n={nonce}", encode(spec))).unwrap_or_default() + ))?; + window.set_size(outer)?; + return Ok(window); + } + let url = format!("drag-ghost.html?spec={}", encode(spec)); + let builder = tauri::WebviewWindowBuilder::new(app, GHOST, tauri::WebviewUrl::App(url.into())) + .title("Dragging tab") + .inner_size(outer.width, outer.height) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .focusable(false) + .focused(false) + .visible(false) + .transparent(true) + .shadow(false); + let window = builder.build()?; + window.set_ignore_cursor_events(true)?; + Ok(window) +} + +fn encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +/// `x, y` is the pill's top-left corner; the window starts one pad earlier. +fn ghost_position(x: f64, y: f64) -> tauri::LogicalPosition { + tauri::LogicalPosition::new(x - GHOST_PAD, y - GHOST_PAD) +} + +fn hide_ghost(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window(GHOST) { + let _ = window.hide(); + } +} + +#[tauri::command] +pub(crate) async fn windows_drag_begin( + app: tauri::AppHandle, + state: tauri::State<'_, Windows>, + tab_key: String, + spec: String, + x: f64, + y: f64, +) -> Result<(), String> { + valid_page_key(&tab_key)?; + if spec.len() > GHOST_SPEC_MAX || !x.is_finite() || !y.is_finite() { + return Err("Invalid drag".into()); + } + state.set_dragging(Some(tab_key)); + let size: GhostSize = serde_json::from_str(&spec).map_err(|_| "Invalid drag")?; + let sane = |v: f64| (1.0..=2000.0).contains(&v); + if !sane(size.width) || !sane(size.height) { + return Err("Invalid drag".into()); + } + let window = ghost(&app, &spec, &size).map_err(|e| e.to_string())?; + window + .set_position(ghost_position(x, y)) + .and_then(|()| window.show()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn windows_drag_move( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, Windows>, + x: f64, + y: f64, +) -> Result<(), String> { + if !x.is_finite() || !y.is_finite() { + return Err("Invalid drag".into()); + } + state.set_drop_target(&app, window_at(&app, window.label(), x, y)); + if let Some(ghost) = app.get_webview_window(GHOST) { + ghost + .set_position(ghost_position(x, y)) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn windows_drag_end( + app: tauri::AppHandle, + state: tauri::State<'_, Windows>, +) -> Result<(), String> { + state.set_drop_target(&app, None); + state.set_dragging(None); + hide_ghost(&app); + Ok(()) +} + +/// Which Buzz window, other than `source` and the drag ghost, is under a logical screen point. +fn window_at( + app: &tauri::AppHandle, + source: &str, + x: f64, + y: f64, +) -> Option { + app.webview_windows() + .into_iter() + .filter(|(label, _)| label != source && label != GHOST) + .find(|(_, window)| { + let Ok(scale) = window.scale_factor() else { + return false; + }; + let (Ok(position), Ok(size)) = (window.outer_position(), window.outer_size()) else { + return false; + }; + let position = position.to_logical::(scale); + let size = size.to_logical::(scale); + x >= position.x + && y >= position.y + && x < position.x + size.width + && y < position.y + size.height + }) + .map(|(label, _)| label) +} + +/// A tab dragged out of its strip and released at a screen point joins the +/// window under the pointer, or opens a new window there. +#[tauri::command] +pub(crate) async fn windows_drop_tab( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, Windows>, + page_key: String, + x: f64, + y: f64, +) -> Result { + state.set_drop_target(&app, None); + state.set_dragging(None); + hide_ghost(&app); + if !x.is_finite() || !y.is_finite() { + return Err("Invalid drop point".into()); + } + let destination = window_at(&app, window.label(), x, y).unwrap_or_else(|| "new".into()); + apply_move(&app, &state, &page_key, &destination, Some((x, y))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "buzz-windows-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + #[test] + fn new_window_moves_a_page_out_of_main_and_back() { + let mut layout = Layout::default(); + let change = layout.move_tab("buzz.channels/channels", "new").unwrap(); + assert_eq!(change.created.as_deref(), Some("tabs-1")); + assert_eq!(layout.windows[0].tabs, ["buzz.channels/channels"]); + // Repeating the request is idempotent rather than creating an empty second window. + let again = layout.move_tab("buzz.channels/channels", "new").unwrap(); + assert_eq!(again, Change::default()); + let change = layout.move_tab("buzz.channels/channels", MAIN).unwrap(); + assert_eq!(change.closed, ["tabs-1"]); + assert!(layout.windows.is_empty()); + } + + #[test] + fn a_page_lives_in_exactly_one_window() { + let mut layout = Layout::default(); + layout.move_tab("buzz.channels/channels", "new").unwrap(); + let change = layout.move_tab("buzz.agents/agents", "new").unwrap(); + assert_eq!(change.created.as_deref(), Some("tabs-2")); + let change = layout.move_tab("buzz.agents/agents", "tabs-1").unwrap(); + assert_eq!(change.closed, ["tabs-2"]); + assert_eq!(layout.windows.len(), 1); + assert_eq!( + layout.windows[0].tabs, + ["buzz.channels/channels", "buzz.agents/agents"] + ); + // Labels are never reused while the layout remembers a higher number. + let change = layout.move_tab("buzz.projects/projects", "new").unwrap(); + assert_eq!(change.created.as_deref(), Some("tabs-2")); + } + + #[test] + fn rejects_invalid_pages_and_unknown_destinations() { + let mut layout = Layout::default(); + for key in [ + "home", + "settings", + "", + "Buzz/Page", + "a/b/c", + "plugin/", + "/page", + "panel:", + "panel:home", + "page:buzz.a/a", + ] { + assert!(layout.move_tab(key, "new").is_err(), "{key}"); + } + assert!(layout + .move_tab("panel:buzz.bestie/companion", "new") + .is_ok()); + assert!(layout.move_tab("buzz.channels/channels", "tabs-9").is_err()); + assert!(layout.move_tab("buzz.channels/channels", "other").is_err()); + assert_eq!(layout.windows.len(), 1); + } + + #[test] + fn closing_a_detached_window_returns_its_tabs_to_main() { + let mut layout = Layout::default(); + layout.move_tab("buzz.channels/channels", "new").unwrap(); + assert!(layout.remove_window("tabs-1")); + assert!(!layout.remove_window("tabs-1")); + assert!(layout.windows.is_empty()); + } + + #[test] + fn persists_and_restores_a_sanitized_layout() { + let dir = temp_dir("persist"); + let windows = Windows::open(Some(&dir), true); + windows + .update(|layout| layout.move_tab("buzz.channels/channels", "new")) + .unwrap(); + windows + .update(|layout| layout.move_tab("buzz.agents/agents", "tabs-1")) + .unwrap(); + let reopened = Windows::open(Some(&dir), true); + assert_eq!(reopened.layout(), windows.layout()); + fs::write( + dir.join(FILE), + r#"{"windows":[{"label":"tabs-3","tabs":["buzz.a/a","buzz.a/a"]},{"label":"bad","tabs":["buzz.b/b"]},{"label":"tabs-4","tabs":[]},{"label":"tabs-5","tabs":["buzz.a/a","home"]}]}"#, + ) + .unwrap(); + let repaired = Windows::open(Some(&dir), true).layout(); + assert_eq!( + repaired, + Layout { + windows: vec![TabWindow { + label: "tabs-3".into(), + tabs: vec!["buzz.a/a".into()], + }], + } + ); + fs::write(dir.join(FILE), "not json").unwrap(); + assert_eq!(Windows::open(Some(&dir), true).layout(), Layout::default()); + fs::remove_dir_all(dir).ok(); + } + + #[test] + fn a_disabled_plugin_discards_the_saved_layout_and_reset_gathers_tabs() { + let dir = temp_dir("disabled"); + let windows = Windows::open(Some(&dir), true); + windows + .update(|layout| layout.move_tab("buzz.channels/channels", "new")) + .unwrap(); + windows + .update(|layout| layout.move_tab("buzz.agents/agents", "new")) + .unwrap(); + assert_eq!(windows.layout().windows.len(), 2); + let (closed, layout) = windows.update(|layout| Ok(layout.reset())).unwrap(); + assert_eq!(closed, ["tabs-1", "tabs-2"]); + assert_eq!(layout, Layout::default()); + assert_eq!(Windows::open(Some(&dir), true).layout(), Layout::default()); + // A layout saved while enabled is dropped, and persisted as dropped, when + // the app launches with the plugin disabled. + windows + .update(|layout| layout.move_tab("buzz.channels/channels", "new")) + .unwrap(); + assert_eq!(Windows::open(Some(&dir), false).layout(), Layout::default()); + assert_eq!(Windows::open(Some(&dir), true).layout(), Layout::default()); + fs::remove_dir_all(dir).ok(); + } + + #[test] + fn without_a_profile_the_layout_is_in_memory_only() { + let windows = Windows::open(None, true); + let (change, layout) = windows + .update(|layout| layout.move_tab("buzz.channels/channels", "new")) + .unwrap(); + assert_eq!(change.created.as_deref(), Some("tabs-1")); + assert_eq!(windows.layout(), layout); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 510871775..e01d63bd3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "title": "Buzz Foundation", diff --git a/src/app/App.tsx b/src/app/App.tsx index 96507bf82..8d81697a5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,7 +2,7 @@ import { Button } from "../shared/design-system/ui/Button"; // FOUNDATION: Startup, navigation, contributed pages, and built-in Settings. import { AgentMentionContext } from "../features/agents/mention-context"; import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { registerAppShortcuts } from "./shortcuts"; import type { AppServices } from "./services"; import { Settings } from "./Settings"; @@ -16,16 +16,56 @@ import { Home } from "./shell/Home"; import { pagePresentation, shellPresentation } from "./shell/presentation"; import { usePanelLauncher } from "./shell/usePanelLauncher"; import { PanelLaunchers } from "./shell/PanelLaunchers"; +import { EmptyWindow } from "./shell/EmptyWindow"; import { PanelCard } from "../features/panels/PanelCard"; +import { PanelView } from "../features/panels/PanelView"; +import { panelTabKey, windowPanels } from "../features/windows/service"; export function App({ services }: { services: AppServices }) { - const { plugins } = services; + const { plugins, windows } = services; const startup = useSyncExternalStore(plugins.subscribe, plugins.startup); const route = useAppNavigation(services); const launcher = usePanelLauncher(services.panels, startup === "ready"); - const home = route.target.kind === "home"; - const settings = route.target.kind === "settings"; - const select = route.select; + const home = route.target.kind === "home" && windows.isMain; + const settings = route.target.kind === "settings" && windows.isMain; + const layout = useSyncExternalStore(windows.subscribe, windows.snapshot); + // Launcher panels follow the window layout too and sit in every window's + // launcher row. A detached window without pages shows one of them full-size. + const panels = windowPanels(layout.layout, windows.label, launcher.available); + const [panelChoice, setPanelChoice] = useState(); + const fullPanelMode = !windows.isMain && route.pages.length === 0; + const panelTab = fullPanelMode + ? (panels.find((panel) => panelTabKey(panel) === panelChoice) ?? panels[0]) + : undefined; + // A detached window keeps its shell when its tabs leave; it never shows Home. + const emptyWindow = + !windows.isMain && + layout.status === "ready" && + startup === "ready" && + route.pages.length === 0 && + panels.length === 0; + const select = (key: string) => { + if (key.startsWith("panel:")) { + setPanelChoice(key); + return; + } + setPanelChoice(undefined); + route.select(key); + }; + // A tab moved here from another window becomes the selected tab, once this + // window's layout shows it. A panel only "selects" where it fills the window. + const activate = layout.activate; + const activated = useRef(0); + useEffect(() => { + if (!activate || activate.seq === activated.current) return; + const key = activate.key; + const present = key.startsWith("panel:") + ? fullPanelMode && panels.some((panel) => panelTabKey(panel) === key) + : route.pages.some((page) => page.key === key); + if (!present) return; + activated.current = activate.seq; + select(key); + }); useEffect( () => registerAppShortcuts( @@ -48,7 +88,10 @@ export function App({ services }: { services: AppServices }) { : route.page ? pagePresentation(route.page) : shellPresentation.settings; - const selectedPanel = launcher.selected; + const selectedPanel = + launcher.selected && panels.includes(launcher.selected) + ? launcher.selected + : undefined; const companion = selectedPanel && ( + fullPanelMode + ? setPanelChoice(panelTabKey(panel)) + : launcher.launch(panel, trigger) + } + windows={windows} + layout={layout.layout} + tabsHere={route.pages.length + panels.length} + detachable={layout.enabled} /> } companion={pageOwnsCompanion ? undefined : companion} pages={startup === "ready" ? route.pages : []} - selected={route.selected} + panelCount={panels.length} + selected={panelTab ? panelTabKey(panelTab) : route.selected} onSelect={select} - tone={presentation.tone} + tone={panelTab ? shellPresentation.home.tone : presentation.tone} workspace={ - startup === "ready" && !home && route.page?.layout === "workspace" + !!panelTab || + (startup === "ready" && !home && route.page?.layout === "workspace") } > - {route.failure || route.state.status === "failed" ? ( + {emptyWindow ? ( + + ) : panelTab ? ( + void windows.moveTab?.(panelTabKey(panelTab), "main")} + /> + ) : route.failure || route.state.status === "failed" ? (

This destination couldn’t open

diff --git a/src/app/external-links.test.mjs b/src/app/external-links.test.mjs index 7044371e0..a53f02d95 100644 --- a/src/app/external-links.test.mjs +++ b/src/app/external-links.test.mjs @@ -14,11 +14,12 @@ it("registers the desktop link fallback in the production native builder", () => ); }); -it("grants only HTTP(S) opening to the main window, without file or application access", () => { +it("grants only HTTP(S) opening to app windows, without file or application access", () => { const capability = JSON.parse( read("../../src-tauri/capabilities/default.json"), ); - expect(capability.windows).toEqual(["main"]); + // Detached tab windows render the same pages and need the same link fallback. + expect(capability.windows).toEqual(["main", "tabs-*"]); expect(capability.remote).toBeUndefined(); const openerPermissions = capability.permissions.filter((permission) => (typeof permission === "string" diff --git a/src/app/navigation.ts b/src/app/navigation.ts index c1515cfb4..f3938954a 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState, useSyncExternalStore, } from "react"; @@ -10,20 +11,29 @@ import { communityDestination } from "../features/communities/destination"; import type { OpenTarget } from "../features/navigation/targets"; import type { PageNavigation } from "../features/navigation/service"; import type { OpenFailure } from "../features/navigation/controller"; +import { windowPages } from "../features/windows/service"; +import { followSavedCommunity } from "../features/windows/follow"; +import { orderPages } from "./shell/presentation"; import { developerMode } from "./Settings"; const channelsKey = "buzz.channels/channels"; export function useAppNavigation(services: AppServices) { const navigation = services.navigation; + const host = services.windows; const state = useSyncExternalStore(navigation.subscribe, navigation.snapshot); const client = useSyncExternalStore( services.communities.subscribe, services.communities.snapshot, ); - const pages = useSyncExternalStore( + const registry = useSyncExternalStore( services.pages.subscribe, services.pages.snapshot, ); + const windows = useSyncExternalStore(host.subscribe, host.snapshot); + const pages = useMemo( + () => windowPages(windows.layout, host.label, registry), + [windows.layout, host.label, registry], + ); const plugins = useSyncExternalStore( services.plugins.subscribe, services.plugins.snapshot, @@ -38,13 +48,21 @@ export function useAppNavigation(services: AppServices) { ? channelsKey : undefined; const page = pages.find((page) => page.key === pageKey); + // A target that belongs to another window (or Home/Settings outside main) is + // redirected to this window's first tab rather than reported as a failure. + const redirecting = + windows.status === "loading" || + ((host.isMain || pages.length > 0) && + ((!host.isMain && + (target.kind === "home" || target.kind === "settings")) || + (!!pageKey && !page && registry.some((item) => item.key === pageKey)))); const membership = scope ? client.memberships.find( (item) => communityDestination(item.id).url === scope.communityOrigin, ) : undefined; let failure: OpenFailure | undefined; - let waiting = false; + let waiting = redirecting; if (scope === null) { waiting = client.status === "loading" || client.selected !== null; } else if (scope) { @@ -160,7 +178,7 @@ export function useAppNavigation(services: AppServices) { if (!waiting && !failure && target.kind === "home") request?.complete({ status: "opened" }); }, [waiting, failure, target, request]); - const select = (key: string) => { + const select = (key: string, options?: { replace?: boolean }) => { const selectedClient = services.communities.snapshot(); let destination: OpenTarget; if (key === "home") destination = { version: 1, kind: "home" }; @@ -184,8 +202,31 @@ export function useAppNavigation(services: AppServices) { : { scope: null }), }; } - void navigation.open(destination); + void navigation.open(destination, options); }; + const latest = useRef({ select, pageKey }); + latest.current = { select, pageKey }; + const first = orderPages(pages)[0]?.key; + useEffect(() => { + if (!redirecting || windows.status === "loading" || startup !== "ready") + return; + if (first) latest.current.select(first, { replace: true }); + else if (host.isMain) latest.current.select("home", { replace: true }); + // A detached window without pages shows its empty state instead. + }, [redirecting, windows.status, startup, first, host.isMain]); + useEffect(() => { + if (host.isMain || !client.viewer) return; + return followSavedCommunity(client.viewer, (selected) => { + const current = services.communities.snapshot(); + if (selected === current.selected) return; + // A community joined in another window needs a relaunch of this one to follow. + if (selected && !current.memberships.some((m) => m.id === selected)) + return; + services.communities.select(selected); + const { pageKey, select } = latest.current; + if (pageKey) select(pageKey, { replace: true }); + }); + }, [host.isMain, client.viewer, services]); return { state, target, @@ -194,6 +235,7 @@ export function useAppNavigation(services: AppServices) { request, select, waiting, + redirecting, failure, selected: pageKey ?? target.kind, retry() { diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 7c6470c4f..9682564f4 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -41,6 +41,16 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services.conversation.links.snapshot()[0].pluginId, "buzz.links", ); + // Detaching is switched on by the bundled Windows plugin through ctx.windows. + await vi.waitFor(() => + assert.equal(services.windows.snapshot().enabled, true), + ); + await services.plugins.change("disable", "buzz.windows"); + assert.equal(services.windows.snapshot().enabled, false); + await services.plugins.change("enable", "buzz.windows"); + await vi.waitFor(() => + assert.equal(services.windows.snapshot().enabled, true), + ); await services.plugins.change("disable", "buzz.links"); assert.equal(services.conversation.links.snapshot().length, 0); await services.plugins.change("enable", "buzz.links"); diff --git a/src/app/services.ts b/src/app/services.ts index c36a3f944..939efdf47 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -18,9 +18,11 @@ import { PagesService } from "../features/pages/service"; import { bundledPlugins } from "../bundled"; import { createPluginManager } from "../plugins/manager"; import { withTimeout } from "../plugins/timeout"; +import { createWindowHost, WindowsService } from "../features/windows/service"; export function createServices() { const appearance = createAppearance(); + const windows = createWindowHost(); const ctx = new Context(); const plugins = createPluginManager(ctx, { bundled: bundledPlugins, @@ -29,6 +31,8 @@ export function createServices() { const navigationHost = provideNavigation(ctx); const navigation = navigationHost.navigation; const shortcuts = new ShortcutsService(ctx); + // Detaching is a host capability the bundled `buzz.windows` plugin switches on. + new WindowsService(ctx, windows); const pages = new PagesService(ctx); const panels = new PanelsService(ctx); const conversation = new ConversationService(ctx); @@ -45,14 +49,19 @@ export function createServices() { undefined, (target) => notificationAuthorized(communities, target), ); - ctx.effect(() => bindMessageNotifications(notifications, communities)); - if (notifications.indicator.available) - ctx.effect(() => - bindUnreadIndicator(communities, notifications.indicator.setUnread), - ); + // Every window runs a full session; only main may raise desktop notifications + // and own the Dock unread badge. + if (windows.isMain) { + ctx.effect(() => bindMessageNotifications(notifications, communities)); + if (notifications.indicator.available) + ctx.effect(() => + bindUnreadIndicator(communities, notifications.indicator.setUnread), + ); + } let disposal: Promise | undefined; return { agentControl, + windows, notifications, navigation, navigationHost, @@ -66,6 +75,7 @@ export function createServices() { appearance, dispose() { appearance.dispose(); + windows.dispose(); // Start root cancellation without waiting for plugin-owned cleanup. Cordis // starts sibling effects independently; the runtime still owns replacement // barriers. A timeout reports incomplete cleanup, never successful disposal. diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index d61510183..c4ec14b98 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -1,12 +1,14 @@ +import { type ReactNode, useSyncExternalStore } from "react"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; -import type { ReactNode } from "react"; import { HouseIcon } from "../../shared/design-system/icons/index"; import { isTauri } from "@tauri-apps/api/core"; import type { RegisteredPage } from "../../features/pages/service"; import type { Communities } from "../../features/communities/service"; +import type { WindowHost } from "../../features/windows/service"; import { CommunitySwitcher } from "../../features/communities/CommunitySwitcher"; import { ProfileButton } from "./ProfileButton"; import { PageSearch, type SearchServices } from "./PageSearch"; +import { PageTab } from "./PageTab"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; import { macTitleBarDragHandlers } from "./title-bar"; @@ -16,11 +18,13 @@ const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; export function AppShell({ pages, + panelCount = 0, selected, onSelect, tone, workspace, communities, + windows, searchServices, navigationControls, onCommunitySelect, @@ -29,11 +33,14 @@ export function AppShell({ children, }: { pages: readonly RegisteredPage[]; + /** Launcher panels living in this window; they count as tabs for move targets. */ + panelCount?: number; selected: string; onSelect: (key: string) => void; tone: string; workspace?: boolean; communities: Communities; + windows: WindowHost; searchServices?: SearchServices; navigationControls?: ReactNode; onCommunitySelect?: (id: string | null) => void; @@ -41,7 +48,16 @@ export function AppShell({ companion?: ReactNode; children: ReactNode; }) { + const layout = useSyncExternalStore(windows.subscribe, windows.snapshot); + // Detached windows carry only the tab strip; community, Settings and profile stay in main. + const main = windows.isMain; + const ordered = orderPages(pages); + const tabsHere = ordered.length + panelCount; const fillsWorkspace = workspace || selected === "settings"; + // A hovering tab highlights where it would land: launcher panels join the + // launcher row; pages join the tab strip. + const hovering = layout.dropTarget?.tab; + const landsInLaunchers = !!hovering && hovering.startsWith("panel:"); return (

{navigationControls} - + {main && ( + + )}
-
diff --git a/src/app/shell/EmptyWindow.tsx b/src/app/shell/EmptyWindow.tsx new file mode 100644 index 000000000..df2c12a08 --- /dev/null +++ b/src/app/shell/EmptyWindow.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import type { WindowHost } from "../../features/windows/service"; + +/** A detached window whose pages were disabled, removed or moved away. */ +export function EmptyWindow({ windows }: { windows: WindowHost }) { + const [error, setError] = useState(); + return ( +
+

No pages in this window

+

+ The pages that lived here were moved, disabled or removed. Move a page + here from another window, or close this one. +

+ {windows.close && ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/src/app/shell/PageTab.ghost.test.ts b/src/app/shell/PageTab.ghost.test.ts new file mode 100644 index 000000000..c2a6794f7 --- /dev/null +++ b/src/app/shell/PageTab.ghost.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { beforeEach, expect, it } from "vitest"; +import { ghostSpec, translucent } from "./PageTab"; + +const ghostPage = readFileSync("public/drag-ghost.js", "utf8"); + +function strip(selectedFirst = true) { + document.body.innerHTML = ` + + `; + const by = (id: string) => document.getElementById(id) as HTMLElement; + return { home: by("home"), messages: by("messages"), bestie: by("bestie") }; +} + +function renderGhost(spec: unknown) { + document.body.innerHTML = `
`; + window.location.hash = `#spec=${encodeURIComponent(JSON.stringify(spec))}`; + new Function(ghostPage)(); + return document.getElementById("tab") as HTMLElement; +} + +beforeEach(() => { + window.location.hash = ""; +}); + +it("an unselected page tab lifts with the selected look, its icon and its text styles", () => { + const { messages } = strip(); + const spec = ghostSpec(messages, "Messages", { width: 120.4, height: 32 }); + expect(spec).toMatchObject({ + title: "Messages", + width: 120, + height: 32, + background: "rgb(10, 20, 30)", + color: "rgb(1, 2, 3)", + padding: "6px 16px", + gap: "6px", + radius: "999px", + }); + expect(spec.icon).toContain(''); + expect(spec.icon).toMatch(/^ { + const { home, bestie } = strip(); + expect(ghostSpec(home, "Home", { width: 90, height: 32 }).background).toBe( + "rgb(10, 20, 30)", + ); + const launcher = ghostSpec(bestie, "Bestie", { width: 36, height: 36 }, true); + expect(launcher.title).toBe(""); + // Translucent glass would wash out over the desktop; borrow the selected surface. + expect(launcher.background).toBe("rgb(10, 20, 30)"); + expect(launcher.shadow).toContain("inset"); + expect(launcher.icon).toMatch(/^ { + for (const color of [ + "transparent", + "rgba(0, 0, 0, 0)", + "rgba(40, 50, 60, 0.4)", + "color(srgb 0.1 0.2 0.3 / 0.5)", + "hsla(10, 20%, 30%, 40%)", + "", + ]) + expect(translucent(color), color).toBe(true); + for (const color of [ + "rgb(1, 2, 3)", + "rgba(1, 2, 3, 1)", + "color(srgb 0.1 0.2 0.3)", + "color(srgb 0.1 0.2 0.3 / 1)", + "hsl(10, 20%, 30%)", + ]) + expect(translucent(color), color).toBe(false); +}); + +it("the ghost page renders the spec and accepts only the tab's own icon element", () => { + const tab = renderGhost({ + title: "Messages", + icon: '', + iconSize: "15px", + width: 120, + height: 32, + background: "rgb(10, 20, 30)", + color: "rgb(1, 2, 3)", + font: "500 14px Inter", + padding: "6px 16px", + gap: "6px", + radius: "999px", + shadow: "rgb(255, 255, 255) 0px 1px 0px 0px inset", + }); + expect(tab.style.width).toBe("120px"); + expect(tab.style.background).toBe("rgb(10, 20, 30)"); + expect(tab.style.borderRadius).toBe("999px"); + expect(tab.style.boxShadow).toContain("inset"); + expect(tab.style.boxShadow).toContain("0.28"); + expect(tab.querySelector("svg")?.style.width).toBe("15px"); + expect(document.getElementById("title")?.textContent).toBe("Messages"); + // Script, remote image and malformed payloads render nothing dangerous. + for (const icon of [ + "", + '', + "
", + ]) { + const next = renderGhost({ title: "x", icon, width: 10, height: 10 }); + expect(next.querySelector("svg, img, script, div")).toBeNull(); + } + expect(renderGhost({}).style.width).toBe("0px"); + document.body.innerHTML = `
stale
`; + window.location.hash = "#spec=not-json"; + new Function(ghostPage)(); + expect(document.getElementById("title")?.textContent).toBe("stale"); +}); diff --git a/src/app/shell/PageTab.test.ts b/src/app/shell/PageTab.test.ts new file mode 100644 index 000000000..127175626 --- /dev/null +++ b/src/app/shell/PageTab.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "vitest"; +import { moveTargets, releasedInStrip } from "./PageTab"; + +const layout = { + windows: [ + { label: "tabs-1", tabs: ["buzz.channels/channels"] }, + { label: "tabs-2", tabs: ["buzz.agents/agents", "buzz.projects/projects"] }, + ], +}; + +test("main tabs can leave to a new or existing window, never to main", () => { + expect(moveTargets(layout, "main", 3)).toEqual([ + { destination: "new", title: "Move to new window" }, + { destination: "tabs-1", title: "Move to Window 2" }, + { destination: "tabs-2", title: "Move to Window 3" }, + ]); +}); + +test("a drag released in the window's own header cancels; anywhere else drops", () => { + const header = { left: 0, top: 0, right: 1200, bottom: 56 }; + const viewport = { width: 1200, height: 800 }; + expect(releasedInStrip({ x: 300, y: 30 }, header, viewport)).toBe(true); + expect(releasedInStrip({ x: 300, y: 56 }, header, viewport)).toBe(false); + expect(releasedInStrip({ x: 300, y: 400 }, header, viewport)).toBe(false); + // Outside the window the client point is negative or past the viewport. + expect(releasedInStrip({ x: -40, y: 30 }, header, viewport)).toBe(false); + expect(releasedInStrip({ x: 300, y: -10 }, header, viewport)).toBe(false); + expect(releasedInStrip({ x: 1300, y: 30 }, header, viewport)).toBe(false); + expect(releasedInStrip({ x: 300, y: 30 }, undefined, viewport)).toBe(false); +}); + +test("a tab alone in a detached window is not offered another new window", () => { + expect(moveTargets(layout, "tabs-1", 1)).toEqual([ + { destination: "main", title: "Move to main window" }, + { destination: "tabs-2", title: "Move to Window 3" }, + ]); + expect(moveTargets(layout, "tabs-2", 2)[0]).toEqual({ + destination: "new", + title: "Move to new window", + }); +}); diff --git a/src/app/shell/PageTab.tsx b/src/app/shell/PageTab.tsx new file mode 100644 index 000000000..571ca7da2 --- /dev/null +++ b/src/app/shell/PageTab.tsx @@ -0,0 +1,380 @@ +import type React from "react"; +import { useEffect, useId, useRef, useState, type ReactElement } from "react"; +import { createPortal } from "react-dom"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { + type GhostSpec, + type WindowHost, + type WindowLayout, + MAIN_WINDOW, + windowTitle, +} from "../../features/windows/service"; + +/** Move targets for one tab: never the window it is already in. */ +export function moveTargets( + layout: WindowLayout, + label: string, + tabsHere: number, +): readonly { destination: string; title: string }[] { + const targets: { destination: string; title: string }[] = []; + // A tab alone in a detached window already has its own window. + if (label === MAIN_WINDOW || tabsHere > 1) + targets.push({ destination: "new", title: "Move to new window" }); + if (label !== MAIN_WINDOW) + targets.push({ destination: MAIN_WINDOW, title: "Move to main window" }); + for (const window of layout.windows) + if (window.label !== label) + targets.push({ + destination: window.label, + title: `Move to ${windowTitle(layout, window.label)}`, + }); + return targets; +} + +type Point = { x: number; y: number }; +type Rect = { left: number; top: number; right: number; bottom: number }; + +/** + * A release inside this window's own header is a cancelled drag (tabs are not + * reordered yet); anywhere else, including outside the window, is a drop. + */ +export function releasedInStrip( + point: Point, + header: Rect | undefined, + viewport: { width: number; height: number }, +): boolean { + if (!header) return false; + if ( + point.x < 0 || + point.y < 0 || + point.x >= viewport.width || + point.y >= viewport.height + ) + return false; + return ( + point.x >= header.left && + point.x < header.right && + point.y >= header.top && + point.y < header.bottom + ); +} + +/** The selected page pill: the opaque surface every lifted tab borrows. */ +const SELECTED_TAB = '.navigation-item[data-variant="pill"][data-selected]'; + +/** + * What the native pill needs to look exactly like `tab`: its size, its icon + * markup and the resolved styles of a selected tab, read from the live DOM so + * the ghost follows the theme without mirroring tokens. Launchers lift as an + * icon-only disc. + */ +export function ghostSpec( + tab: HTMLElement, + title: string, + rect: { width: number; height: number }, + launcher = false, +): GhostSpec { + const own = getComputedStyle(tab); + // Unselected tabs are transparent and launchers are translucent glass that + // relies on a backdrop the floating pill does not have; both lift with the + // opaque selected-tab surface. + const selected = translucent(own.backgroundColor) + ? tab.ownerDocument.querySelector(SELECTED_TAB) + : undefined; + const icon = tab.querySelector("svg, img"); + return { + title: launcher ? "" : title, + ...(icon + ? { icon: icon.outerHTML, iconSize: getComputedStyle(icon).width } + : {}), + width: Math.round(rect.width), + height: Math.round(rect.height), + background: selected + ? getComputedStyle(selected).backgroundColor + : own.backgroundColor, + color: own.color, + font: own.font, + padding: own.padding, + gap: own.gap, + radius: own.borderRadius, + shadow: own.boxShadow, + }; +} + +/** Whether a computed CSS color has any transparency (`transparent`, rgba/color alpha < 1). */ +export function translucent(color: string): boolean { + if (color === "transparent" || color === "") return true; + // Alpha is the 4th comma component (rgba/hsla) or follows a slash (color()/modern syntax). + const alpha = + /\/\s*([\d.]+%?)\s*\)$/.exec(color) ?? + /^[a-z]+\((?:[^,()]+,){3}\s*([\d.]+%?)\s*\)$/i.exec(color); + const raw = alpha?.[1]; + if (raw === undefined) return false; + const value = raw.endsWith("%") + ? Number(raw.slice(0, -1)) / 100 + : Number(raw); + return value < 1; +} + +/** + * A tab (page pill) or launcher (round icon button for a panel) with a + * browser-like context menu for moving it between windows. `tabKey` is the + * layout key: a page contribution key or `panel:`. + */ +export function PageTab({ + tabKey, + name, + icon, + launcher = false, + selected, + onSelect, + windows, + layout, + tabsHere, + detachable, + expanded, +}: { + tabKey: string; + /** Tab label; the launcher's accessible name and the native drag ghost's title. */ + name: string; + icon: ReactElement; + /** Render as a launcher disc instead of a page pill. */ + launcher?: boolean; + selected: boolean; + onSelect(event: React.MouseEvent): void; + windows: WindowHost; + layout: WindowLayout; + tabsHere: number; + /** Whether moving between windows is available (desktop with the plugin on). */ + detachable: boolean; + expanded?: boolean; +}) { + // Anchored below the tab but rendered in a portal: the tab strip scrolls + // horizontally, which would otherwise clip anything hanging below it. + const [anchor, setAnchor] = useState<{ top: number; left: number }>(); + const [error, setError] = useState(); + const menu = useRef(null); + const trigger = useRef(null); + const id = useId(); + const open = !!anchor; + const setOpen = (next: boolean) => { + if (!next) return setAnchor(undefined); + const rect = trigger.current?.getBoundingClientRect(); + if (rect) setAnchor({ top: rect.bottom + 8, left: rect.left }); + }; + const targets = + detachable && windows.moveTab + ? moveTargets(layout, windows.label, tabsHere) + : []; + useEffect(() => { + if (!open) return; + const dismiss = (event: Event) => { + const target = event.target as Node; + if (!menu.current?.contains(target) && !trigger.current?.contains(target)) + setAnchor(undefined); + }; + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setAnchor(undefined); + trigger.current?.focus(); + }; + document.addEventListener("pointerdown", dismiss); + document.addEventListener("focusin", dismiss); + document.addEventListener("keydown", onEscape); + return () => { + document.removeEventListener("pointerdown", dismiss); + document.removeEventListener("focusin", dismiss); + document.removeEventListener("keydown", onEscape); + }; + }, [open]); + const report = (reason: unknown) => + setError(reason instanceof Error ? reason.message : String(reason)); + const move = async (destination: string) => { + setOpen(false); + setError(undefined); + try { + await windows.moveTab?.(tabKey, destination); + } catch (reason) { + report(reason); + } + }; + // Browser-style tab dragging with pointer events: HTML drag-and-drop cannot + // leave a webview, but macOS keeps delivering pointer moves and the release + // to the window where the press began, so Rust can resolve the drop point and + // move the native ghost that stays visible beyond this window's edge. + const drag = useRef<{ + x: number; + y: number; + active: boolean; + /** Pointer offset inside the tab, so the pill moves as the tab itself. */ + grab: { x: number; y: number }; + }>(undefined); + const dragged = useRef(false); + // While lifted the tab's slot stays but the tab itself is the native pill. + const [lifted, setLifted] = useState(false); + const endDrag = () => { + if (drag.current?.active) windows.drag?.end(); + drag.current = undefined; + setLifted(false); + }; + const lift = (event: React.PointerEvent) => { + const tab = event.currentTarget; + const rect = tab.getBoundingClientRect(); + const grab = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + // Client-to-screen translation at this instant, whatever the window chrome. + const dx = event.screenX - event.clientX; + const dy = event.screenY - event.clientY; + windows.drag?.begin( + tabKey, + ghostSpec(tab, name, rect, launcher), + rect.left + dx, + rect.top + dy, + ); + setLifted(true); + return grab; + }; + const control = { + ref: trigger, + "aria-expanded": expanded, + "aria-haspopup": targets.length ? ("menu" as const) : undefined, + "aria-controls": open ? id : undefined, + "data-lifted": lifted || undefined, + // Native drag-and-drop of any child (images, selected text) would take + // the gesture away from the pointer-based tab drag. + onDragStart: (event: React.DragEvent) => event.preventDefault(), + onClick: (event: React.MouseEvent) => { + if (dragged.current) { + dragged.current = false; + return; + } + onSelect(event); + }, + onContextMenu: (event: React.MouseEvent) => { + if (!targets.length) return; + event.preventDefault(); + event.currentTarget.focus(); + setOpen(true); + }, + onPointerDown: (event: React.PointerEvent) => { + if (!detachable || !windows.dropTab || event.button !== 0) return; + drag.current = { + x: event.clientX, + y: event.clientY, + active: false, + grab: { x: 0, y: 0 }, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + onPointerMove: (event: React.PointerEvent) => { + const state = drag.current; + if (!state) return; + if (!state.active) { + if (Math.hypot(event.clientX - state.x, event.clientY - state.y) < 6) + return; + state.active = true; + setAnchor(undefined); + state.grab = lift(event); + } else + windows.drag?.move( + event.screenX - state.grab.x, + event.screenY - state.grab.y, + ); + }, + onPointerUp: (event: React.PointerEvent) => { + const state = drag.current; + // The drop command hides the ghost itself; only a cancel needs an explicit end. + if (state?.active) drag.current = undefined; + else endDrag(); + if (!state?.active) return; + // The click that follows this release is the drag's, not a selection. + dragged.current = true; + setTimeout(() => { + dragged.current = false; + }, 0); + const header = event.currentTarget + .closest("header") + ?.getBoundingClientRect(); + if ( + releasedInStrip({ x: event.clientX, y: event.clientY }, header, { + width: window.innerWidth, + height: window.innerHeight, + }) + ) { + windows.drag?.end(); + setLifted(false); + return; + } + setError(undefined); + // A successful drop unmounts this tab here; only failure puts it back. + windows + .dropTab?.(tabKey, event.screenX, event.screenY) + .catch((reason) => { + windows.drag?.end(); + setLifted(false); + report(reason); + }); + }, + onPointerCancel: endDrag, + }; + return ( + <> + {launcher ? ( + + ) : ( + + )} + {targets.length > 0 && + anchor && + createPortal( + , + document.body, + )} + {error && + createPortal( +

+ {error} +

, + document.body, + )} + + ); +} diff --git a/src/app/shell/PanelLaunchers.tsx b/src/app/shell/PanelLaunchers.tsx index 599042e43..a8a7ccce9 100644 --- a/src/app/shell/PanelLaunchers.tsx +++ b/src/app/shell/PanelLaunchers.tsx @@ -1,35 +1,51 @@ -import { IconButton } from "../../shared/design-system/ui/IconButton"; import { useState } from "react"; import { SidebarIcon } from "../../shared/design-system/icons/index"; import type { RegisteredPanel } from "../../features/panels/service"; +import { + type WindowHost, + type WindowLayout, + panelTabKey, +} from "../../features/windows/service"; +import { PageTab } from "./PageTab"; export function PanelLaunchers({ panels, selected, launch, + windows, + layout, + tabsHere, + detachable, }: { panels: readonly RegisteredPanel[]; selected: RegisteredPanel | undefined; launch(panel: RegisteredPanel, trigger: HTMLButtonElement): void; + windows: WindowHost; + layout: WindowLayout; + tabsHere: number; + detachable: boolean; }) { return panels .filter((panel) => panel.launcher) .map((panel) => ( - launch(panel, event.currentTarget)} + tabKey={panelTabKey(panel)} + name={panel.title} + launcher icon={ } + selected={false} + expanded={panel === selected} + onSelect={(event) => launch(panel, event.currentTarget)} + windows={windows} + layout={layout} + tabsHere={tabsHere} + detachable={detachable} /> )); } @@ -41,6 +57,8 @@ function LauncherIcon({ src }: { src: string }) { setFailed(true)} /> diff --git a/src/bundled/index.ts b/src/bundled/index.ts index a07ef360a..0ae236463 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -25,6 +25,8 @@ import linksManifest from "./links/manifest.json"; import * as links from "./links"; import sessionsManifest from "./sessions/manifest.json"; import * as sessions from "./sessions"; +import windowsManifest from "./windows/manifest.json"; +import * as windowsPlugin from "./windows"; export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...activityManifest, apiVersion: 1 }, module: activity }, @@ -40,4 +42,5 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...agentsManifest, apiVersion: 1 }, module: agents }, { manifest: { ...workflowsManifest, apiVersion: 1 }, module: workflows }, { manifest: { ...sessionsManifest, apiVersion: 1 }, module: sessions }, + { manifest: { ...windowsManifest, apiVersion: 1 }, module: windowsPlugin }, ]; diff --git a/src/bundled/windows/index.ts b/src/bundled/windows/index.ts new file mode 100644 index 000000000..6a4252fc7 --- /dev/null +++ b/src/bundled/windows/index.ts @@ -0,0 +1,9 @@ +// The switch for detaching tabs into their own desktop windows. The windowing +// runtime is host-owned (src/features/windows, src-tauri/src/windows.rs); this +// plugin turns it on, and disabling it gathers every tab back into main. +import type { PluginModule } from "../../plugins/api"; + +export const inject = ["windows"]; +export const apply: PluginModule["apply"] = (ctx) => { + ctx.effect(() => ctx.windows.enable()); +}; diff --git a/src/bundled/windows/manifest.json b/src/bundled/windows/manifest.json new file mode 100644 index 000000000..d14edcbae --- /dev/null +++ b/src/bundled/windows/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.windows", "name": "Windows", "apiVersion": 1 } diff --git a/src/features/windows/follow.test.ts b/src/features/windows/follow.test.ts new file mode 100644 index 000000000..46e3b131f --- /dev/null +++ b/src/features/windows/follow.test.ts @@ -0,0 +1,45 @@ +import { expect, it, vi } from "vitest"; +import { followSavedCommunity } from "./follow"; + +const viewer = "ab".repeat(32); +const key = `buzz-client.v1:${viewer}`; + +function host() { + const store = new Map(); + const listeners = new Set<(event: StorageEvent) => void>(); + return { + addEventListener: (_: string, fn: EventListenerOrEventListenerObject) => + listeners.add(fn as (event: StorageEvent) => void), + removeEventListener: (_: string, fn: EventListenerOrEventListenerObject) => + listeners.delete(fn as (event: StorageEvent) => void), + localStorage: { + getItem: (name: string) => store.get(name) ?? null, + } as Storage, + write(name: string, value: string | null) { + if (value === null) store.delete(name); + else store.set(name, value); + for (const fn of listeners) fn({ key: name } as unknown as StorageEvent); + }, + size: () => listeners.size, + }; +} + +it("reports canonical saved selections for this viewer only", () => { + const h = host(); + const onChange = vi.fn(); + const stop = followSavedCommunity(viewer, onChange, h); + h.write( + key, + JSON.stringify({ selected: "wss://relay.example.com/", memberships: [] }), + ); + h.write(key, JSON.stringify({ selected: null })); + h.write("buzz-client.v1:other", JSON.stringify({ selected: "wss://x.test" })); + h.write(key, "not json"); + h.write(key, JSON.stringify({ selected: 42 })); + // Canonical ids are the relay's https origin, whatever scheme was saved. + expect(onChange.mock.calls).toEqual([["https://relay.example.com"], [null]]); + stop(); + h.write(key, JSON.stringify({ selected: null })); + expect(onChange).toHaveBeenCalledTimes(2); + expect(h.size()).toBe(0); +}); diff --git a/src/features/windows/follow.ts b/src/features/windows/follow.ts new file mode 100644 index 000000000..7604a993f --- /dev/null +++ b/src/features/windows/follow.ts @@ -0,0 +1,33 @@ +import { communityDestination } from "../communities/destination"; + +/** + * Detached windows have no community switcher; they follow the selection the + * main window saves. Reports the canonical community id, or null for Personal + * space, each time the saved client preferences for `viewer` change. + */ +export function followSavedCommunity( + viewer: string, + onChange: (selected: string | null) => void, + host: Pick & + Pick = window, +): () => void { + const key = `buzz-client.v1:${viewer}`; + const changed = (event: StorageEvent) => { + if (event.key !== key && event.key !== null) return; + let selected: string | null; + try { + const raw: unknown = JSON.parse(host.localStorage.getItem(key) ?? "null"); + const saved = + raw && typeof raw === "object" && "selected" in raw + ? raw.selected + : null; + if (saved !== null && typeof saved !== "string") return; + selected = saved ? communityDestination(saved).id : null; + } catch { + return; // Unreadable preferences are the main window's problem to report. + } + onChange(selected); + }; + host.addEventListener("storage", changed); + return () => host.removeEventListener("storage", changed); +} diff --git a/src/features/windows/service.test.ts b/src/features/windows/service.test.ts new file mode 100644 index 000000000..f84d73776 --- /dev/null +++ b/src/features/windows/service.test.ts @@ -0,0 +1,344 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it, vi } from "vitest"; +import type { RegisteredPage } from "../pages/service"; +import type { RegisteredPanel } from "../panels/service"; +import { + createTabDrag, + createWindowHost, + WindowsService, + panelTabKey, + parseLayout, + windowPages, + windowPanels, + windowTitle, + type WindowTransport, +} from "./service"; + +const page = (key: string): RegisteredPage => { + const [pluginId = "", id = ""] = key.split("/"); + return { key, pluginId, id, title: id, revision: "r", component: () => null }; +}; +const messages = page("buzz.channels/channels"); +const projects = page("buzz.projects/projects"); +const agents = page("buzz.agents/agents"); +const pages = [messages, projects, agents]; +const panel = (key: string, launcher = true): RegisteredPanel => { + const [pluginId = "", id = ""] = key.split("/"); + return { + key, + pluginId, + id, + title: id, + revision: "r", + matches: () => false, + component: () => null, + ...(launcher ? { launcher: { icon: "/x.png", target: "" } } : {}), + }; +}; +const bestie = panel("buzz.bestie/companion"); +const activity = panel("buzz.agent-activity/activity"); +const github = panel("buzz.github/object", false); +const layout = { + windows: [ + { + label: "tabs-1", + tabs: [ + "buzz.channels/channels", + "buzz.agents/agents", + "panel:buzz.bestie/companion", + ], + }, + { label: "tabs-3", tabs: ["missing.plugin/page"] }, + ], +}; + +function transport(initial: unknown | (() => Promise) = layout) { + let deliver: ((layout: unknown) => void) | undefined; + let activate: ((key: unknown) => void) | undefined; + let hover: ((hovering: unknown) => void) | undefined; + const unlisten = vi.fn(); + const api: WindowTransport & { + emit(layout: unknown): void; + activate(key: unknown): void; + hover(hovering: unknown): void; + } = { + label: "tabs-1", + layout: vi.fn(() => + typeof initial === "function" + ? (initial as () => Promise)() + : Promise.resolve(initial), + ), + listen: vi.fn(async (listener: (layout: unknown) => void) => { + deliver = listener; + return unlisten; + }), + activation: vi.fn(async (listener: (key: unknown) => void) => { + activate = listener; + return unlisten; + }), + dropTarget: vi.fn(async (listener: (hovering: unknown) => void) => { + hover = listener; + return unlisten; + }), + moveTab: vi.fn(async () => ({ windows: [] })), + dropTab: vi.fn(async () => layout), + dragBegin: vi.fn(async () => {}), + dragMove: vi.fn(async () => {}), + dragEnd: vi.fn(async () => {}), + reset: vi.fn(async () => ({ windows: [] })), + close: vi.fn(async () => {}), + emit: (layout) => deliver?.(layout), + activate: (key) => activate?.(key), + hover: (hovering) => hover?.(hovering), + }; + return { api, unlisten }; +} +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + +it("main shows every page not assigned elsewhere; detached windows show only their tabs", () => { + expect(windowPages(layout, "main", pages)).toEqual([projects]); + expect(windowPages(layout, "tabs-1", pages)).toEqual([messages, agents]); + expect(windowPages(layout, "tabs-3", pages)).toEqual([]); + expect(windowPages(layout, "tabs-9", pages)).toEqual([]); + expect(windowPages({ windows: [] }, "main", pages)).toEqual(pages); + expect(windowTitle(layout, "main")).toBe("Main window"); +}); + +it("launcher panels move like tabs; target-only panels never leave their pages", () => { + const panels = [bestie, github, activity]; + expect(panelTabKey(bestie)).toBe("panel:buzz.bestie/companion"); + expect(windowPanels(layout, "main", panels)).toEqual([activity]); + expect(windowPanels(layout, "tabs-1", panels)).toEqual([bestie]); + expect(windowPanels({ windows: [] }, "main", panels)).toEqual([ + bestie, + activity, + ]); + // A page and a panel with the same contribution key never collide. + expect( + windowPages(layout, "main", [page("buzz.bestie/companion")]), + ).toHaveLength(1); + expect(windowTitle(layout, "tabs-3")).toBe("Window 3"); + expect(windowTitle(layout, "tabs-9")).toBe("Window"); +}); + +it("rejects malformed layouts instead of silently reassigning pages", () => { + for (const bad of [null, {}, { windows: {} }, { windows: [{ label: 1 }] }]) + expect(() => parseLayout(bad)).toThrow("Invalid window layout"); + expect(parseLayout({ windows: [] })).toEqual({ windows: [] }); +}); + +it("web is the single main window without move or close", () => { + const host = createWindowHost(undefined); + expect(host.label).toBe("main"); + expect(host.isMain).toBe(true); + expect(host.snapshot()).toEqual({ + status: "ready", + layout: { windows: [] }, + enabled: false, + }); + expect(host.moveTab).toBeUndefined(); + expect(host.dropTab).toBeUndefined(); + expect(host.close).toBeUndefined(); + host.dispose(); +}); + +it("loads the desktop layout, follows broadcasts, and stops listening on dispose", async () => { + const { api, unlisten } = transport(); + const host = createWindowHost(api); + const listener = vi.fn(); + host.subscribe(listener); + expect(host.isMain).toBe(false); + expect(host.snapshot().status).toBe("loading"); + await settled(); + expect(host.snapshot()).toEqual({ + status: "ready", + layout, + enabled: false, + }); + expect(listener).toHaveBeenCalledTimes(1); + api.emit({ windows: [] }); + expect(host.snapshot().layout).toEqual({ windows: [] }); + api.emit("garbage"); + expect(host.snapshot().layout).toEqual({ windows: [] }); + expect(listener).toHaveBeenCalledTimes(2); + await host.moveTab?.("buzz.agents/agents", "new"); + expect(api.moveTab).toHaveBeenCalledWith("buzz.agents/agents", "new"); + expect(listener).toHaveBeenCalledTimes(3); + await host.dropTab?.("buzz.agents/agents", 120.5, 40); + expect(api.dropTab).toHaveBeenCalledWith("buzz.agents/agents", 120.5, 40); + expect(host.snapshot().layout).toEqual(layout); + expect(listener).toHaveBeenCalledTimes(4); + await host.close?.(); + expect(api.close).toHaveBeenCalledOnce(); + // Activations ride the same snapshot and survive layout updates; repeats get a new seq. + api.activate("buzz.agents/agents"); + expect(host.snapshot().activate).toEqual({ + key: "buzz.agents/agents", + seq: 1, + }); + api.emit(layout); + expect(host.snapshot().activate).toEqual({ + key: "buzz.agents/agents", + seq: 1, + }); + api.activate("buzz.agents/agents"); + expect(host.snapshot().activate?.seq).toBe(2); + api.activate(42); + expect(host.snapshot().activate?.seq).toBe(2); + expect(listener).toHaveBeenCalledTimes(7); + // The hovering tab rides the snapshot; repeats and junk are ignored. + expect(host.snapshot().dropTarget).toBeUndefined(); + api.hover({ tab: "panel:buzz.bestie/companion" }); + api.hover({ tab: "panel:buzz.bestie/companion" }); + api.hover("yes"); + api.hover({ tab: 7 }); + api.hover({}); + expect(host.snapshot().dropTarget).toEqual({ + tab: "panel:buzz.bestie/companion", + }); + expect(listener).toHaveBeenCalledTimes(8); + api.emit(layout); + expect(host.snapshot().dropTarget?.tab).toBe("panel:buzz.bestie/companion"); + api.hover({ tab: null }); + api.hover({ tab: null }); + expect(host.snapshot().dropTarget).toBeUndefined(); + expect(listener).toHaveBeenCalledTimes(10); + host.dispose(); + expect(unlisten).toHaveBeenCalledTimes(3); + api.emit(layout); + api.activate("buzz.projects/projects"); + api.hover({ tab: "buzz.agents/agents" }); + expect(listener).toHaveBeenCalledTimes(10); +}); + +it("drag moves wait for begin, keep only the latest point, and never outlive end", async () => { + const gates: (() => void)[] = []; + const gated = () => + new Promise((resolve) => { + gates.push(resolve); + }); + const transport = { + dragBegin: vi.fn(gated), + dragMove: vi.fn(gated), + dragEnd: vi.fn(async () => {}), + }; + const drag = createTabDrag(transport); + const spec = { + title: "Messages", + width: 120, + height: 32, + background: "", + color: "", + font: "", + padding: "", + gap: "", + radius: "", + shadow: "", + }; + drag.begin("buzz.channels/channels", spec, 10, 10); + drag.move(20, 20); + drag.move(30, 30); + expect(transport.dragBegin).toHaveBeenCalledWith( + "buzz.channels/channels", + spec, + 10, + 10, + ); + expect(transport.dragMove).not.toHaveBeenCalled(); + gates.shift()?.(); // begin settles + await settled(); + expect(transport.dragMove).toHaveBeenCalledExactlyOnceWith(30, 30); + drag.move(40, 40); + drag.move(50, 50); + gates.shift()?.(); // first move settles + await settled(); + expect(transport.dragMove).toHaveBeenLastCalledWith(50, 50); + expect(transport.dragMove).toHaveBeenCalledTimes(2); + drag.move(60, 60); + drag.end(); + gates.shift?.()?.(); + await settled(); + expect(transport.dragMove).toHaveBeenCalledTimes(2); + expect(transport.dragEnd).toHaveBeenCalledOnce(); + // A stale begin settling after end must not revive a move. + drag.begin("buzz.agents/agents", { ...spec, title: "Agents" }, 1, 1); + drag.move(2, 2); + drag.end(); + gates.shift()?.(); + await settled(); + expect(transport.dragMove).toHaveBeenCalledTimes(2); + expect(transport.dragEnd).toHaveBeenCalledTimes(2); +}); + +it("the plugin switch enables detaching; switching off resets the layout, teardown does not", async () => { + const { api } = transport(); + const host = createWindowHost(api); + const listener = vi.fn(); + host.subscribe(listener); + await settled(); + expect(host.snapshot().enabled).toBe(false); + const release = host.enable(); + const again = host.enable(); + expect(host.snapshot().enabled).toBe(true); + expect(host.snapshot().layout).toEqual(layout); + expect(listener).toHaveBeenCalledTimes(2); + // One enabler leaving keeps the switch on; the disposer is idempotent. + release(); + release(); + expect(host.snapshot().enabled).toBe(true); + expect(api.reset).not.toHaveBeenCalled(); + again(); + expect(host.snapshot().enabled).toBe(false); + expect(api.reset).toHaveBeenCalledOnce(); + await settled(); + expect(host.snapshot().layout).toEqual({ windows: [] }); + // App teardown disposes plugins after the host: no reset on quit. + const late = host.enable(); + host.dispose(); + late(); + expect(api.reset).toHaveBeenCalledOnce(); +}); + +it("the Cordis capability mirrors the host for plugins", () => { + const { api } = transport(); + const host = createWindowHost(api); + const ctx = new Context(); + new WindowsService(ctx, host); + expect(ctx.windows.label).toBe("tabs-1"); + expect(ctx.windows.isMain).toBe(false); + expect(ctx.windows.snapshot()).toBe(host.snapshot()); + const release = ctx.windows.enable(); + expect(host.snapshot().enabled).toBe(true); + release(); + expect(host.snapshot().enabled).toBe(false); + host.dispose(); +}); + +it("fails open as a single window when the layout cannot be read", async () => { + const { api } = transport(() => Promise.reject(new Error("ipc down"))); + const host = createWindowHost(api); + await settled(); + expect(host.snapshot()).toEqual({ + status: "ready", + layout: { windows: [] }, + enabled: false, + }); + host.dispose(); +}); + +it("a broadcast that lands before the initial read wins", async () => { + let resolveInitial: ((layout: unknown) => void) | undefined; + const { api } = transport( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ); + const host = createWindowHost(api); + await settled(); + api.emit({ windows: [] }); + resolveInitial?.(layout); + await settled(); + expect(host.snapshot().layout).toEqual({ windows: [] }); + host.dispose(); +}); diff --git a/src/features/windows/service.ts b/src/features/windows/service.ts new file mode 100644 index 000000000..10e8c8b6e --- /dev/null +++ b/src/features/windows/service.ts @@ -0,0 +1,420 @@ +// Desktop tab windows. Rust owns the layout (which pages live in which window); +// this reader mirrors it for one webview and filters the page registry to the +// tabs assigned to this window's label. Web is always the single main window. +import { Service, type Context } from "@deepseek-ai/cordis"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { RegisteredPage } from "../pages/service"; +import type { RegisteredPanel } from "../panels/service"; + +export const MAIN_WINDOW = "main"; +const EVENT = "buzz:windows"; +const ACTIVATE = "buzz:windows:activate"; +const DROP_TARGET = "buzz:windows:drop-target"; +/** Launcher panels share the layout with pages under a distinct tab key. */ +export const panelTabKey = (panel: RegisteredPanel) => `panel:${panel.key}`; + +export type TabWindow = Readonly<{ label: string; tabs: readonly string[] }>; +export type WindowLayout = Readonly<{ windows: readonly TabWindow[] }>; +export type WindowSnapshot = Readonly<{ + status: "loading" | "ready"; + layout: WindowLayout; + /** Detaching is switched on by the `buzz.windows` plugin; off, tabs stay put. */ + enabled: boolean; + /** The tab most recently moved into this window; `seq` distinguishes repeats. */ + activate?: Readonly<{ key: string; seq: number }>; + /** The tab dragged from another window that is hovering this one, if any. */ + dropTarget?: Readonly<{ tab: string }>; +}>; +/** `main`, `new`, or the label of an open detached window. */ +export type WindowDestination = string; + +export type WindowTransport = Readonly<{ + label: string; + layout(): Promise; + listen(listener: (layout: unknown) => void): Promise<() => void>; + /** A tab moved into this window; it should become the selected tab. */ + activation(listener: (tabKey: unknown) => void): Promise<() => void>; + dropTarget(listener: (payload: unknown) => void): Promise<() => void>; + moveTab(pageKey: string, destination: WindowDestination): Promise; + dropTab(pageKey: string, x: number, y: number): Promise; + dragBegin( + tabKey: string, + spec: GhostSpec, + x: number, + y: number, + ): Promise; + dragMove(x: number, y: number): Promise; + dragEnd(): Promise; + /** Return every tab to main and close detached windows. */ + reset(): Promise; + close(): Promise; +}>; + +/** Appearance of the native pill: the lifted tab's size, icon and resolved styles. */ +export type GhostSpec = Readonly<{ + title: string; + /** Serialized ``/`` from the tab; the ghost page renders nothing else. */ + icon?: string; + /** Rendered icon box (CSS length), since the ghost lacks the tab's stylesheet. */ + iconSize?: string; + width: number; + height: number; + background: string; + color: string; + font: string; + padding: string; + gap: string; + radius: string; + /** The tab's own box-shadow (glass rims); the ghost adds its drop shadow. */ + shadow: string; +}>; + +/** + * Native drag ghost that stays visible outside this window. Coordinates are + * the pill's top-left corner on the logical screen. + */ +export type TabDrag = Readonly<{ + begin(tabKey: string, spec: GhostSpec, x: number, y: number): void; + move(x: number, y: number): void; + end(): void; +}>; + +export type WindowHost = Readonly<{ + label: string; + isMain: boolean; + snapshot(): WindowSnapshot; + subscribe(listener: () => void): () => void; + /** Absent where windows cannot be detached (web). */ + moveTab?: (pageKey: string, destination: WindowDestination) => Promise; + /** Release a dragged tab at logical screen coordinates: merge or open there. */ + dropTab?: (pageKey: string, x: number, y: number) => Promise; + drag?: TabDrag; + /** Absent for the main window and on web. */ + close?: () => Promise; + /** + * Switch detaching on for this window; the disposer switches it off and, on + * desktop, returns every tab to main. Host teardown never resets the layout. + */ + enable(): () => void; + dispose(): void; +}>; + +/** Plugin-facing view of the window this plugin instance runs in. */ +export type Windows = Pick< + WindowHost, + "label" | "isMain" | "snapshot" | "subscribe" | "enable" +>; +declare module "@deepseek-ai/cordis" { + interface Context { + windows: Windows; + } +} + +export class WindowsService extends Service implements Windows { + readonly label: string; + readonly isMain: boolean; + constructor( + ctx: Context, + private readonly host: WindowHost, + ) { + super(ctx, "windows"); + this.label = host.label; + this.isMain = host.isMain; + } + snapshot = () => this.host.snapshot(); + subscribe = (listener: () => void) => this.host.subscribe(listener); + enable = () => this.host.enable(); +} + +const EMPTY: WindowLayout = Object.freeze({ windows: Object.freeze([]) }); + +export function parseLayout(value: unknown): WindowLayout { + if (!value || typeof value !== "object" || !("windows" in value)) + throw new Error("Invalid window layout"); + const windows = value.windows; + if (!Array.isArray(windows)) throw new Error("Invalid window layout"); + return Object.freeze({ + windows: Object.freeze( + windows.map((window: unknown): TabWindow => { + if ( + !window || + typeof window !== "object" || + !("label" in window) || + typeof window.label !== "string" || + !("tabs" in window) || + !Array.isArray(window.tabs) || + !window.tabs.every((tab) => typeof tab === "string") + ) + throw new Error("Invalid window layout"); + return Object.freeze({ + label: window.label, + tabs: Object.freeze([...(window.tabs as string[])]), + }); + }), + ), + }); +} + +function windowTabs( + layout: WindowLayout, + label: string, + items: readonly T[], + key: (item: T) => string, +): readonly T[] { + if (label === MAIN_WINDOW) { + const detached = new Set(layout.windows.flatMap((window) => window.tabs)); + return items.filter((item) => !detached.has(key(item))); + } + const tabs = layout.windows.find((window) => window.label === label)?.tabs; + return tabs ? items.filter((item) => tabs.includes(key(item))) : []; +} + +/** Pages shown in `label`: main owns every page not assigned to a detached window. */ +export function windowPages( + layout: WindowLayout, + label: string, + pages: readonly RegisteredPage[], +): readonly RegisteredPage[] { + return windowTabs(layout, label, pages, (page) => page.key); +} + +/** Launcher panels in `label`: launchers in main, full tabs in detached windows. */ +export function windowPanels( + layout: WindowLayout, + label: string, + panels: readonly RegisteredPanel[], +): readonly RegisteredPanel[] { + return windowTabs( + layout, + label, + panels.filter((panel) => panel.launcher), + panelTabKey, + ); +} + +/** Display names for move targets, stable across windows: Window 2, Window 3… */ +export function windowTitle(layout: WindowLayout, label: string): string { + if (label === MAIN_WINDOW) return "Main window"; + const index = layout.windows.findIndex((window) => window.label === label); + return index === -1 ? "Window" : `Window ${index + 2}`; +} + +export function tauriWindowTransport(): WindowTransport { + // A partial Tauri runtime (IPC without window metadata, as browser fixtures + // provide) is treated as the main window rather than failing startup. + const current = (() => { + try { + return getCurrentWindow(); + } catch { + return undefined; + } + })(); + return { + label: current?.label ?? MAIN_WINDOW, + layout: () => invoke("windows_layout"), + listen: (listener) => + listen(EVENT, (event) => listener(event.payload)), + activation: (listener) => + listen(ACTIVATE, (event) => listener(event.payload)), + dropTarget: (listener) => + listen(DROP_TARGET, (event) => listener(event.payload)), + moveTab: (pageKey, destination) => + invoke("windows_move_tab", { pageKey, destination }), + dropTab: (pageKey, x, y) => invoke("windows_drop_tab", { pageKey, x, y }), + dragBegin: (tabKey, spec, x, y) => + invoke("windows_drag_begin", { + tabKey, + spec: JSON.stringify(spec), + x, + y, + }), + dragMove: (x, y) => invoke("windows_drag_move", { x, y }), + dragEnd: () => invoke("windows_drag_end"), + reset: () => invoke("windows_reset"), + close: () => current?.close() ?? Promise.resolve(), + }; +} + +/** + * Coalesces pointer moves: at most one position IPC in flight, always the + * latest point, and a move never overtakes its begin or outlives its end. + */ +export function createTabDrag( + transport: Pick, +): TabDrag { + let session = 0; + let pending: { x: number; y: number } | undefined; + let inflight: Promise | undefined; + const flush = (current: number) => { + if (inflight || !pending || current !== session) return; + const point = pending; + pending = undefined; + inflight = transport + .dragMove(point.x, point.y) + .catch(() => {}) + .then(() => { + inflight = undefined; + flush(current); + }); + }; + return { + begin(tabKey, spec, x, y) { + const current = ++session; + pending = undefined; + inflight = transport + .dragBegin(tabKey, spec, x, y) + .catch(() => {}) + .then(() => { + inflight = undefined; + flush(current); + }); + }, + move(x, y) { + pending = { x, y }; + flush(session); + }, + end() { + session++; + pending = undefined; + const settle = inflight ?? Promise.resolve(); + void settle.then(() => transport.dragEnd()).catch(() => {}); + }, + }; +} + +export function createWindowHost( + transport: WindowTransport | undefined = isTauri() + ? tauriWindowTransport() + : undefined, +): WindowHost { + const listeners = new Set<() => void>(); + let snapshot: WindowSnapshot = Object.freeze({ + status: transport ? "loading" : "ready", + layout: EMPTY, + enabled: false, + }); + let disposed = false; + const stops: (() => void)[] = []; + const notify = () => { + for (const listener of listeners) listener(); + }; + const accept = (value: unknown) => { + if (disposed) return; + let layout: WindowLayout; + try { + layout = parseLayout(value); + } catch { + return; // Keep the last good layout; a malformed broadcast is not a reassignment. + } + snapshot = Object.freeze({ ...snapshot, status: "ready", layout }); + notify(); + }; + let seq = 0; + const activate = (key: unknown) => { + if (disposed || typeof key !== "string") return; + snapshot = Object.freeze({ ...snapshot, activate: { key, seq: ++seq } }); + notify(); + }; + const hover = (payload: unknown) => { + if ( + disposed || + !payload || + typeof payload !== "object" || + !("tab" in payload) + ) + return; + const tab = payload.tab; + if (tab !== null && typeof tab !== "string") return; + if ((snapshot.dropTarget?.tab ?? null) === tab) return; + const { dropTarget: _, ...rest } = snapshot; + snapshot = Object.freeze( + tab === null ? rest : { ...rest, dropTarget: Object.freeze({ tab }) }, + ); + notify(); + }; + const track = (subscription: Promise<() => void>) => + void subscription + .then((unlisten) => { + if (disposed) unlisten(); + else stops.push(unlisten); + }) + .catch(() => {}); + if (transport) { + let live = false; + // Register for changes before reading, so a move landing mid-fetch is never lost. + track( + transport.listen((layout) => { + live = true; + accept(layout); + }), + ); + track(transport.activation(activate)); + track(transport.dropTarget(hover)); + void transport + .layout() + .then((layout) => { + if (!live) accept(layout); + }) + .catch(() => { + // Fail open as one window: main shows every page, a detached window shows its empty state. + if (!live) accept(EMPTY); + }); + } + const label = transport?.label ?? MAIN_WINDOW; + let enablers = 0; + const setEnabled = (enabled: boolean) => { + if (disposed || snapshot.enabled === enabled) return; + snapshot = Object.freeze({ ...snapshot, enabled }); + notify(); + }; + return { + label, + isMain: label === MAIN_WINDOW, + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + enable() { + enablers += 1; + setEnabled(true); + let released = false; + return () => { + if (released) return; + released = true; + enablers -= 1; + if (enablers > 0) return; + setEnabled(false); + // The plugin was switched off, not the app: gather every tab back into main. + if (transport && !disposed) + transport + .reset() + .then(accept) + .catch(() => {}); + }; + }, + ...(transport + ? { + moveTab: async (pageKey: string, destination: WindowDestination) => { + accept(await transport.moveTab(pageKey, destination)); + }, + dropTab: async (pageKey: string, x: number, y: number) => { + accept(await transport.dropTab(pageKey, x, y)); + }, + drag: createTabDrag(transport), + } + : {}), + ...(transport && label !== MAIN_WINDOW + ? { close: () => transport.close() } + : {}), + dispose() { + disposed = true; + for (const stop of stops) stop(); + listeners.clear(); + }, + }; +} diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 04a570956..4e5f3ceaa 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -68,6 +68,11 @@ export type { NotificationInput, NotificationCategoryDescriptor, } from "../features/notifications/service"; +export type { + Windows, + WindowLayout, + WindowSnapshot, +} from "../features/windows/service"; export type { AgentControl, diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index ad44cda65..478df46e0 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -196,9 +196,37 @@ .shell-pages { @apply glass-primary flex min-w-0 items-center gap-1 overflow-x-auto rounded-full p-1; max-width: min(calc(100vw - 400px), 640px); + transition: + box-shadow 120ms ease-out, + background-color 120ms ease-out; + } + /* Tabs move with pointer events; their contents must not start a native drag. */ + .shell-pages *, + .shell-actions-group * { + -webkit-user-drag: none; + user-select: none; + } + /* A tab being dragged has left its slot for the native pill; the slot waits. */ + .shell-header [data-lifted] { + opacity: 0; } .shell-actions { - @apply flex items-center justify-end gap-2; + @apply flex items-center justify-end; + } + .shell-actions-group { + @apply flex items-center gap-2 rounded-full; + transition: + box-shadow 120ms ease-out, + background-color 120ms ease-out; + } + /* A tab dragged from another window is hovering this one: the strip for + pages, the launcher row for a panel returning to main. */ + .shell-pages[data-drop-target], + .shell-actions-group[data-drop-target] { + @apply bg-surface; + box-shadow: + 0 0 0 2px var(--focus), + 0 0 0 6px color-mix(in srgb, var(--focus) 25%, transparent); } @media (max-width: 700px) { .shell-header {