From a5fdaa8e79eb705cbaf2842fb93fb8535aac627e Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 14 Sep 2026 15:19:52 -0400 Subject: [PATCH 1/5] feat(shell): detach pages and launcher panels into tab windows Desktop windows act as browser-like tab containers: any page or launcher panel (Bestie, Agent Activity) can move to a new or existing window by right-click menu or by dragging, while Home and Settings stay in main. Rust owns the layout (persisted per profile), creates, restores and closes windows, hit-tests drops, and drives a native always-on-top drag ghost that stays visible beyond the source window and follows the app theme. The destination window is focused, highlights while hovered and selects the moved tab. Each window runs the full app on its assigned tabs; only main raises notifications, and detached windows follow the community selected in main. Signed-off-by: Thomas Petersen --- Cargo.lock | 1 + crates/plugin-manager/src/lib.rs | 4 + ...001-feat-detachable-plugin-windows-plan.md | 173 +++++ docs/shell-design.md | 29 +- public/drag-ghost.html | 63 ++ public/drag-ghost.js | 29 + src-tauri/Cargo.toml | 4 +- src-tauri/capabilities/default.json | 7 +- src-tauri/src/lib.rs | 25 +- src-tauri/src/windows.rs | 606 ++++++++++++++++++ src-tauri/tauri.conf.json | 1 + src/app/App.tsx | 78 ++- src/app/external-links.test.mjs | 5 +- src/app/navigation.ts | 50 +- src/app/services.ts | 8 +- src/app/shell/AppShell.tsx | 103 ++- src/app/shell/EmptyWindow.tsx | 33 + src/app/shell/PageTab.test.ts | 41 ++ src/app/shell/PageTab.tsx | 264 ++++++++ src/app/shell/PanelLaunchers.tsx | 40 +- src/features/windows/follow.test.ts | 45 ++ src/features/windows/follow.ts | 33 + src/features/windows/service.test.ts | 275 ++++++++ src/features/windows/service.ts | 306 +++++++++ src/shared/styles/globals.css | 10 + src/shared/theme/service.test.ts | 39 ++ 26 files changed, 2209 insertions(+), 63 deletions(-) create mode 100644 docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md create mode 100644 public/drag-ghost.html create mode 100644 public/drag-ghost.js create mode 100644 src-tauri/src/windows.rs create mode 100644 src/app/shell/EmptyWindow.tsx create mode 100644 src/app/shell/PageTab.test.ts create mode 100644 src/app/shell/PageTab.tsx create mode 100644 src/features/windows/follow.test.ts create mode 100644 src/features/windows/follow.ts create mode 100644 src/features/windows/service.test.ts create mode 100644 src/features/windows/service.ts diff --git a/Cargo.lock b/Cargo.lock index 5cdae71d7..a36f47505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "mac-notification-sys", "portable-pty", "serde", + "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 63886dd3f..67c1a0fd2 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -168,6 +168,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..b7e7284a6 --- /dev/null +++ b/docs/plans/2026-09-14-001-feat-detachable-plugin-windows-plan.md @@ -0,0 +1,173 @@ +--- +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: a launcher in main, a full tab in a detached window, and a + panel's own close returns it to main. 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. A moved page remounts, exactly as it does on + a revision change today. + +### 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 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/shell-design.md b/docs/shell-design.md index 098bbf884..9d2e00c05 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -62,11 +62,34 @@ lights have a reserved 104px left area before the community switcher only in the macOS desktop runtime. This inset does not move the centered tabs. Web gets no inset or imitation window controls. Other platforms retain their native decorations. Drag regions are limited to the -header background; controls remain clickable. The main-window capability grants -only titlebar dragging and the internal native maximize action used by Tauri's drag -handler, plus scoped HTTP(S) opening for [external links](channels.md#run-the-integration). +header background; controls remain clickable. The window capability (`main` and +detached `tabs-*` windows) grants only titlebar dragging, the internal native +maximize action used by Tauri's drag 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) + +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**. A launcher panel +becomes a full tab in a detached window (`panel:`); its close action +returns it 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 and +the tab strip 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..a35b8323f --- /dev/null +++ b/public/drag-ghost.html @@ -0,0 +1,63 @@ + + + + + Dragging tab + + + +
+ + + diff --git a/public/drag-ghost.js b/public/drag-ghost.js new file mode 100644 index 000000000..48fe18dd8 --- /dev/null +++ b/public/drag-ghost.js @@ -0,0 +1,29 @@ +// Native drag ghost: an always-on-top window Rust moves under the pointer while +// a tab is dragged. The title arrives in the query string on creation and via +// hashchange on reuse; this page needs no IPC and has no capability. +// Appearance is read from the same-origin storage the app windows save to +// (keys as in appearance-init.js), on every show so a theme change is followed. +const render = () => { + let mode = "light"; + let scale = 1; + try { + if (localStorage.getItem("buzz-appearance.v1") === "dark") mode = "dark"; + const value = Number(localStorage.getItem("buzz-font-scale.v1")); + if (Number.isFinite(value) && value >= 0.8 && value <= 2) + scale = Math.round(value * 10) / 10; + } catch { + // Storage may be denied; the light palette is the safe default. + } + document.documentElement.dataset.colorMode = mode; + document.documentElement.style.setProperty( + "--buzz-text-scale", + String(scale), + ); + const title = + new URLSearchParams(location.hash.slice(1)).get("title") || + new URLSearchParams(location.search).get("title") || + ""; + document.getElementById("tab").textContent = title; +}; +window.addEventListener("hashchange", render); +render(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95ce2f332..0d846e925 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,11 +13,13 @@ crate-type = ["staticlib", "cdylib", "rlib"] 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" } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } +serde_json = "1" uuid = { version = "1", features = ["v4"] } url = "2" 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 a2a84f1ca..ddd8a1c49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,16 @@ mod notifications; mod terminal; +mod windows; use notifications::{notification_show, Notifications}; use tauri::Manager as _; 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, +}; use buzzodz_plugins::{ imports::{prepare_folder, prepare_git, PreparedImport, Preview}, @@ -155,14 +160,32 @@ async fn plugin_recover( } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let manager = Manager::from_env(); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .manage(Imports::default()) .manage(Terminals::default()) .manage(Notifications::default()) - .manage(PluginManager(Manager::from_env())) + .manage(Windows::open(manager.as_ref().ok().map(Manager::root))) + .manage(PluginManager(manager)) + .setup(|app| { + windows::restore(app.handle()); + Ok(()) + }) + .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(tauri::generate_handler![ + windows_layout, + windows_move_tab, + windows_drop_tab, + windows_drag_begin, + windows_drag_move, + windows_drag_end, notification_show, terminal_create_owner, terminal_spawn, diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs new file mode 100644 index 000000000..797784763 --- /dev/null +++ b/src-tauri/src/windows.rs @@ -0,0 +1,606 @@ +//! 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 _}; + +pub(crate) const MAIN: &str = "main"; +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) + } + /// 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>, +} + +impl Windows { + /// Without a profile directory the layout lives only for this process. + pub(crate) fn open(profile: Option<&Path>) -> Self { + let path = profile.map(|root| root.join(FILE)); + let layout = path + .as_deref() + .and_then(|path| fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default() + .sanitize(); + Self { + path, + layout: Mutex::new(layout), + drop_target: Mutex::new(None), + } + } + /// 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, false); + } + if let Some(label) = &next { + let _ = app.emit_to(label, DROP_TARGET, true); + } + *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() +} + +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"; +const GHOST_OFFSET: (f64, f64) = (14.0, 14.0); + +fn ghost(app: &tauri::AppHandle, title: &str) -> tauri::Result { + if let Some(window) = app.get_webview_window(GHOST) { + // The nonce makes every show a hashchange, so the ghost re-reads the + // title and the appearance 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!("#title={}&n={nonce}", encode(title))) + .unwrap_or_default() + ))?; + return Ok(window); + } + let url = format!("drag-ghost.html?title={}", encode(title)); + let builder = tauri::WebviewWindowBuilder::new(app, GHOST, tauri::WebviewUrl::App(url.into())) + .title("Dragging tab") + .inner_size(180.0, 44.0) + .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() +} + +fn ghost_position(x: f64, y: f64) -> tauri::LogicalPosition { + tauri::LogicalPosition::new(x + GHOST_OFFSET.0, y + GHOST_OFFSET.1) +} + +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, + title: String, + x: f64, + y: f64, +) -> Result<(), String> { + if title.chars().count() > 120 || !x.is_finite() || !y.is_finite() { + return Err("Invalid drag".into()); + } + let window = ghost(&app, title.trim()).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); + 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); + 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)); + 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)); + 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)).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)).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); + 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 63bb33575..414c3b28c 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 bc0653b07..212a53c12 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ // FOUNDATION: Startup, navigation, contributed pages, and built-in Settings. -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"; @@ -13,16 +13,55 @@ 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: launchers in main, tabs elsewhere. + const panels = windowPanels(layout.layout, windows.label, launcher.available); + const [panelChoice, setPanelChoice] = useState(); + const panelTab = windows.isMain + ? undefined + : (panels.find((panel) => panelTabKey(panel) === panelChoice) ?? + (route.pages.length === 0 ? 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. Main keeps launcher panels as launchers. + 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:") + ? !windows.isMain && panels.some((panel) => panelTabKey(panel) === key) + : route.pages.some((page) => page.key === key); + if (!present) return; + activated.current = activate.seq; + select(key); + }); useEffect( () => registerAppShortcuts( @@ -45,7 +84,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 && ( } companion={pageOwnsCompanion ? undefined : companion} pages={startup === "ready" ? route.pages : []} - selected={route.selected} + panelTabs={windows.isMain || startup !== "ready" ? [] : panels} + 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 79c49dc35..03876dde7 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,19 +11,28 @@ 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"; 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, @@ -37,13 +47,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) { @@ -158,7 +176,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" }; @@ -182,8 +200,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, @@ -192,6 +233,7 @@ export function useAppNavigation(services: AppServices) { request, select, waiting, + redirecting, failure, selected: pageKey ?? target.kind, retry() { diff --git a/src/app/services.ts b/src/app/services.ts index ed644fe58..4d7b5bead 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -15,9 +15,11 @@ import { PagesService } from "../features/pages/service"; import { bundledPlugins } from "../bundled"; import { createPluginManager } from "../plugins/manager"; import { withTimeout } from "../plugins/timeout"; +import { createWindowHost } from "../features/windows/service"; export function createServices() { const appearance = createAppearance(); + const windows = createWindowHost(); const ctx = new Context(); const plugins = createPluginManager(ctx, { bundled: bundledPlugins, @@ -40,9 +42,12 @@ export function createServices() { undefined, (target) => notificationAuthorized(communities, target), ); - ctx.effect(() => bindMessageNotifications(notifications, communities)); + // Every window runs a full session; only main may raise desktop notifications. + if (windows.isMain) + ctx.effect(() => bindMessageNotifications(notifications, communities)); let disposal: Promise | undefined; return { + windows, notifications, navigation, navigationHost, @@ -56,6 +61,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 5091fced3..bb34b3a1e 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -1,11 +1,15 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useSyncExternalStore } from "react"; import { House } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; import type { RegisteredPage } from "../../features/pages/service"; +import type { RegisteredPanel } from "../../features/panels/service"; import type { Communities } from "../../features/communities/service"; +import { type WindowHost, panelTabKey } from "../../features/windows/service"; import { CommunitySwitcher } from "../../features/communities/CommunitySwitcher"; import { ProfileButton } from "./ProfileButton"; import { PageSearch } from "./PageSearch"; +import { PageTab } from "./PageTab"; +import { LauncherIcon } from "./PanelLaunchers"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; @@ -13,11 +17,13 @@ const macDesktop = isTauri() && /Mac/i.test(navigator.platform); export function AppShell({ pages, + panelTabs = [], selected, onSelect, tone, workspace, communities, + windows, navigationControls, onCommunitySelect, launchers, @@ -25,17 +31,25 @@ export function AppShell({ children, }: { pages: readonly RegisteredPage[]; + /** Launcher panels shown as tabs (detached windows only). */ + panelTabs?: readonly RegisteredPanel[]; selected: string; onSelect: (key: string) => void; tone: string; workspace?: boolean; communities: Communities; + windows: WindowHost; navigationControls?: ReactNode; onCommunitySelect?: (id: string | null) => void; launchers?: ReactNode; 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 + panelTabs.length; return (

{navigationControls} - + {main && ( + + )}
- `; + 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 }); + 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.tsx b/src/app/shell/PageTab.tsx index 8ee8944c8..20464e538 100644 --- a/src/app/shell/PageTab.tsx +++ b/src/app/shell/PageTab.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { + type GhostSpec, type WindowHost, type WindowLayout, MAIN_WINDOW, @@ -57,6 +58,62 @@ export function releasedInStrip( ); } +/** + * 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. + */ +export function ghostSpec( + tab: HTMLElement, + title: string, + rect: { width: number; height: number }, +): 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( + '.shell-tab[aria-current="page"]', + ) + : undefined; + const icon = tab.querySelector( + ":scope > svg, :scope > img", + ); + return { + title: tab.classList.contains("shell-icon") ? "" : 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) or launcher (panel) with a browser-like context menu for moving * it between windows. `tabKey` is the layout key: a page contribution key or @@ -140,11 +197,36 @@ export function PageTab({ // 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 }>(undefined); + 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), + rect.left + dx, + rect.top + dy, + ); + setLifted(true); + return grab; }; return ( <> @@ -158,6 +240,10 @@ export function PageTab({ aria-expanded={expanded} aria-haspopup={targets.length ? "menu" : 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) => event.preventDefault()} onClick={(event) => { if (dragged.current) { dragged.current = false; @@ -173,7 +259,12 @@ export function PageTab({ }} onPointerDown={(event) => { if (!windows.dropTab || event.button !== 0) return; - drag.current = { x: event.clientX, y: event.clientY, active: false }; + drag.current = { + x: event.clientX, + y: event.clientY, + active: false, + grab: { x: 0, y: 0 }, + }; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { @@ -186,8 +277,12 @@ export function PageTab({ return; state.active = true; setAnchor(undefined); - windows.drag?.begin(name, event.screenX, event.screenY); - } else windows.drag?.move(event.screenX, event.screenY); + state.grab = lift(event); + } else + windows.drag?.move( + event.screenX - state.grab.x, + event.screenY - state.grab.y, + ); }} onPointerUp={(event) => { const state = drag.current; @@ -210,13 +305,16 @@ export function PageTab({ }) ) { 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); }); }} diff --git a/src/app/shell/PanelLaunchers.tsx b/src/app/shell/PanelLaunchers.tsx index 0e56972c1..48706098a 100644 --- a/src/app/shell/PanelLaunchers.tsx +++ b/src/app/shell/PanelLaunchers.tsx @@ -55,6 +55,8 @@ export function LauncherIcon({ src, size }: { src: string; size?: string }) { setFailed(true)} /> diff --git a/src/features/windows/service.test.ts b/src/features/windows/service.test.ts index 772e74dfc..c023d1653 100644 --- a/src/features/windows/service.test.ts +++ b/src/features/windows/service.test.ts @@ -131,7 +131,6 @@ it("web is the single main window without move or close", () => { expect(host.snapshot()).toEqual({ status: "ready", layout: { windows: [] }, - dropTarget: false, }); expect(host.moveTab).toBeUndefined(); expect(host.dropTab).toBeUndefined(); @@ -150,7 +149,6 @@ it("loads the desktop layout, follows broadcasts, and stops listening on dispose expect(host.snapshot()).toEqual({ status: "ready", layout, - dropTarget: false, }); expect(listener).toHaveBeenCalledTimes(1); api.emit({ windows: [] }); @@ -183,23 +181,28 @@ it("loads the desktop layout, follows broadcasts, and stops listening on dispose api.activate(42); expect(host.snapshot().activate?.seq).toBe(2); expect(listener).toHaveBeenCalledTimes(7); - // Drop-target hover is a flag on the snapshot; repeats and junk are ignored. - expect(host.snapshot().dropTarget).toBe(false); - api.hover(true); - api.hover(true); + // 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"); - expect(host.snapshot().dropTarget).toBe(true); + 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).toBe(true); - api.hover(false); - expect(host.snapshot().dropTarget).toBe(false); + 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(true); + api.hover({ tab: "buzz.agents/agents" }); expect(listener).toHaveBeenCalledTimes(10); }); @@ -215,10 +218,27 @@ it("drag moves wait for begin, keep only the latest point, and never outlive end dragEnd: vi.fn(async () => {}), }; const drag = createTabDrag(transport); - drag.begin("Messages", 10, 10); + 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("Messages", 10, 10); + expect(transport.dragBegin).toHaveBeenCalledWith( + "buzz.channels/channels", + spec, + 10, + 10, + ); expect(transport.dragMove).not.toHaveBeenCalled(); gates.shift()?.(); // begin settles await settled(); @@ -236,7 +256,7 @@ it("drag moves wait for begin, keep only the latest point, and never outlive end expect(transport.dragMove).toHaveBeenCalledTimes(2); expect(transport.dragEnd).toHaveBeenCalledOnce(); // A stale begin settling after end must not revive a move. - drag.begin("Agents", 1, 1); + drag.begin("buzz.agents/agents", { ...spec, title: "Agents" }, 1, 1); drag.move(2, 2); drag.end(); gates.shift()?.(); @@ -252,7 +272,6 @@ it("fails open as a single window when the layout cannot be read", async () => { expect(host.snapshot()).toEqual({ status: "ready", layout: { windows: [] }, - dropTarget: false, }); host.dispose(); }); diff --git a/src/features/windows/service.ts b/src/features/windows/service.ts index d69cd1586..e0d935e1a 100644 --- a/src/features/windows/service.ts +++ b/src/features/windows/service.ts @@ -21,8 +21,8 @@ export type WindowSnapshot = Readonly<{ layout: WindowLayout; /** The tab most recently moved into this window; `seq` distinguishes repeats. */ activate?: Readonly<{ key: string; seq: number }>; - /** A tab dragged from another window is hovering this one. */ - dropTarget: boolean; + /** 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; @@ -33,18 +33,45 @@ export type WindowTransport = Readonly<{ 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: (hovering: 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(title: string, x: number, y: number): Promise; + dragBegin( + tabKey: string, + spec: GhostSpec, + x: number, + y: number, + ): Promise; dragMove(x: number, y: number): Promise; dragEnd(): Promise; close(): Promise; }>; -/** Native drag ghost that stays visible outside this window. Screen coordinates. */ +/** 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(title: string, x: number, y: number): void; + begin(tabKey: string, spec: GhostSpec, x: number, y: number): void; move(x: number, y: number): void; end(): void; }>; @@ -151,7 +178,13 @@ export function tauriWindowTransport(): WindowTransport { moveTab: (pageKey, destination) => invoke("windows_move_tab", { pageKey, destination }), dropTab: (pageKey, x, y) => invoke("windows_drop_tab", { pageKey, x, y }), - dragBegin: (title, x, y) => invoke("windows_drag_begin", { title, 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"), close: () => current.close(), @@ -181,11 +214,11 @@ export function createTabDrag( }); }; return { - begin(title, x, y) { + begin(tabKey, spec, x, y) { const current = ++session; pending = undefined; inflight = transport - .dragBegin(title, x, y) + .dragBegin(tabKey, spec, x, y) .catch(() => {}) .then(() => { inflight = undefined; @@ -214,7 +247,6 @@ export function createWindowHost( let snapshot: WindowSnapshot = Object.freeze({ status: transport ? "loading" : "ready", layout: EMPTY, - dropTarget: false, }); let disposed = false; const stops: (() => void)[] = []; @@ -238,10 +270,21 @@ export function createWindowHost( snapshot = Object.freeze({ ...snapshot, activate: { key, seq: ++seq } }); notify(); }; - const hover = (hovering: unknown) => { - if (disposed || typeof hovering !== "boolean") return; - if (snapshot.dropTarget === hovering) return; - snapshot = Object.freeze({ ...snapshot, dropTarget: hovering }); + 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>) => diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index e630e7ef9..64f59850d 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -212,16 +212,35 @@ box-shadow 120ms ease-out, background-color 120ms ease-out; } - /* A tab dragged from another window is hovering this one. */ - .shell-pages[data-drop-target] { + /* Tabs move with pointer events; their contents must not start a native drag. */ + .shell-tab > *, + .shell-icon > * { + -webkit-user-drag: none; + user-select: none; + } + /* A tab being dragged has left its slot for the native pill; the slot waits. */ + .shell-tab[data-lifted], + .shell-icon[data-lifted] { + opacity: 0; + } + .shell-actions { + @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/70; box-shadow: 0 0 0 2px var(--focus), 0 0 0 6px color-mix(in srgb, var(--focus) 25%, transparent); } - .shell-actions { - @apply flex items-center justify-end gap-2; - } @media (max-width: 700px) { .shell-header { @apply h-auto gap-x-2 gap-y-1 px-2 pb-2 pt-2; diff --git a/src/shared/theme/service.test.ts b/src/shared/theme/service.test.ts index 47838b6b9..fe9ea1446 100644 --- a/src/shared/theme/service.test.ts +++ b/src/shared/theme/service.test.ts @@ -74,45 +74,6 @@ it.each([null, "", "system", "LIGHT", '{"mode":"dark"}', "light", "dark"])( }, ); -it.each([ - [null, "light", 1], - ["dark", "dark", 1], - ["dark", "dark", 1.5], - ["light", "light", 1], -])( - "the drag ghost follows the stored appearance %j at scale %d", - (stored, mode, scale) => { - const b = browser(stored); - b.values.set(FONT_SCALE_KEY, String(scale)); - const tab = { textContent: "" }; - const hash = { fn: undefined as (() => void) | undefined }; - const context = { - localStorage: b.storage, - document: { ...b.host.document, getElementById: () => tab }, - location: { hash: "", search: "?title=Messages" }, - URLSearchParams, - window: { - addEventListener: (_: string, fn: () => void) => { - hash.fn = fn; - }, - }, - }; - runInNewContext(readFileSync("public/drag-ghost.js", "utf8"), context); - expect(b.root.dataset.colorMode).toBe(mode); - expect(b.root.style.setProperty).toHaveBeenLastCalledWith( - "--buzz-text-scale", - String(scale), - ); - expect(tab.textContent).toBe("Messages"); - // Reuse: a later show updates title and appearance together. - b.values.set(APPEARANCE_KEY, mode === "dark" ? "light" : "dark"); - context.location.hash = "#title=Bestie&n=1"; - hash.fn?.(); - expect(tab.textContent).toBe("Bestie"); - expect(b.root.dataset.colorMode).toBe(mode === "dark" ? "light" : "dark"); - }, -); - it("persists a choice, applies the document, notifies, and restores on a new lifetime", () => { const b = browser(); const app = createAppearance(b.host); From 060886307e3c33ea8737a05bd5b2cb7455271ba0 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sat, 19 Sep 2026 07:29:57 +0200 Subject: [PATCH 3/5] feat(shell): keep launcher panels in the launcher row of every window A panel dropped on a detached window landed in its tab strip while the drop highlight pointed at the strip too. Launcher panels now live in the right-hand launcher row wherever they are and open as the companion card there; a detached window without pages shows its panel full-size and that view's close returns it to main. The strip no longer renders panel tabs. Signed-off-by: Thomas Petersen --- ...001-feat-detachable-plugin-windows-plan.md | 7 ++- docs/shell-design.md | 12 +++-- src/app/App.tsx | 25 +++++---- src/app/shell/AppShell.tsx | 54 ++++++------------- src/app/shell/PanelLaunchers.tsx | 6 +-- 5 files changed, 47 insertions(+), 57 deletions(-) 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 index b7e7284a6..430a4b994 100644 --- 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 @@ -45,8 +45,11 @@ the main window. - 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: a launcher in main, a full tab in a detached window, and a - panel's own close returns it to main. Target-bound panels (GitHub, Profile, + `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".)* diff --git a/docs/shell-design.md b/docs/shell-design.md index 86859cf79..8dd1ff688 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -80,11 +80,13 @@ 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**. A launcher panel -becomes a full tab in a detached window (`panel:`); its close action -returns it 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 and -the tab strip and follow the community selected in `main`. Rust owns the layout +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 diff --git a/src/app/App.tsx b/src/app/App.tsx index 212a53c12..865c14a7c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -26,13 +26,14 @@ export function App({ services }: { services: AppServices }) { 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: launchers in main, tabs elsewhere. + // 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 panelTab = windows.isMain - ? undefined - : (panels.find((panel) => panelTabKey(panel) === panelChoice) ?? - (route.pages.length === 0 ? panels[0] : undefined)); + 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 && @@ -49,14 +50,14 @@ export function App({ services }: { services: AppServices }) { route.select(key); }; // A tab moved here from another window becomes the selected tab, once this - // window's layout shows it. Main keeps launcher panels as launchers. + // 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:") - ? !windows.isMain && panels.some((panel) => panelTabKey(panel) === key) + ? fullPanelMode && panels.some((panel) => panelTabKey(panel) === key) : route.pages.some((page) => page.key === key); if (!present) return; activated.current = activate.seq; @@ -110,8 +111,12 @@ export function App({ services }: { services: AppServices }) { launchers={ + fullPanelMode + ? setPanelChoice(panelTabKey(panel)) + : launcher.launch(panel, trigger) + } windows={windows} layout={layout.layout} tabsHere={route.pages.length + panels.length} @@ -119,7 +124,7 @@ export function App({ services }: { services: AppServices }) { } companion={pageOwnsCompanion ? undefined : companion} pages={startup === "ready" ? route.pages : []} - panelTabs={windows.isMain || startup !== "ready" ? [] : panels} + panelCount={panels.length} selected={panelTab ? panelTabKey(panelTab) : route.selected} onSelect={select} tone={panelTab ? shellPresentation.home.tone : presentation.tone} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 5bab07051..a771d9aef 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -2,14 +2,12 @@ import { type ReactNode, useSyncExternalStore } from "react"; import { House } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; import type { RegisteredPage } from "../../features/pages/service"; -import type { RegisteredPanel } from "../../features/panels/service"; import type { Communities } from "../../features/communities/service"; -import { type WindowHost, panelTabKey } from "../../features/windows/service"; +import type { WindowHost } from "../../features/windows/service"; import { CommunitySwitcher } from "../../features/communities/CommunitySwitcher"; import { ProfileButton } from "./ProfileButton"; import { PageSearch } from "./PageSearch"; import { PageTab } from "./PageTab"; -import { LauncherIcon } from "./PanelLaunchers"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; @@ -17,7 +15,7 @@ const macDesktop = isTauri() && /Mac/i.test(navigator.platform); export function AppShell({ pages, - panelTabs = [], + panelCount = 0, selected, onSelect, tone, @@ -31,8 +29,8 @@ export function AppShell({ children, }: { pages: readonly RegisteredPage[]; - /** Launcher panels shown as tabs (detached windows only). */ - panelTabs?: readonly RegisteredPanel[]; + /** Launcher panels living in this window; they count as tabs for move targets. */ + panelCount?: number; selected: string; onSelect: (key: string) => void; tone: string; @@ -49,12 +47,12 @@ export function AppShell({ // 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 + panelTabs.length; + const tabsHere = ordered.length + panelCount; const fillsWorkspace = workspace || selected === "settings"; - // A hovering tab highlights where it would land: launcher panels return to - // main's launcher row; everything else joins the tab strip. + // 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 && main && hovering.startsWith("panel:"); + const landsInLaunchers = !!hovering && hovering.startsWith("panel:"); return (
); })} - {panelTabs.map((panel) => { - const key = panelTabKey(panel); - return ( - onSelect(key)} - windows={windows} - layout={layout.layout} - tabsHere={tabsHere} - > - - {panel.title} - - ); - })}
- {main && ( -
- {launchers} - +
+ {launchers} + {main && } + {main && ( onSelect("settings")} /> -
- )} + )} +
diff --git a/src/app/shell/PanelLaunchers.tsx b/src/app/shell/PanelLaunchers.tsx index 48706098a..d71f77a0f 100644 --- a/src/app/shell/PanelLaunchers.tsx +++ b/src/app/shell/PanelLaunchers.tsx @@ -46,8 +46,8 @@ export function PanelLaunchers({ )); } -/** Launcher icons are sized by `.shell-icon`; `size` is for use inside a tab. */ -export function LauncherIcon({ src, size }: { src: string; size?: string }) { +/** Sized by `.shell-icon`. */ +export function LauncherIcon({ src }: { src: string }) { const [failed, setFailed] = useState(false); return failed ? (