diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index a15d481b58..d220acb5d5 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -449,6 +449,8 @@ pub fn bundled_manifests() -> Vec { .expect("mesh manifest"), serde_json::from_str(include_str!("../../../src/bundled/huddles/manifest.json")) .expect("huddles manifest"), + serde_json::from_str(include_str!("../../../src/bundled/popout/manifest.json")) + .expect("popout manifest"), builderlab, serde_json::from_str(include_str!("../../../src/bundled/pairing/manifest.json")) .expect("valid pairing manifest"), @@ -549,6 +551,7 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.links" | "buzz.voice-notes" | "buzz.huddles" + | "buzz.popout" | "buzz.github" | "buzz.bestie" | "buzz.watching" @@ -2668,6 +2671,26 @@ mod tests { assert!(huddles().enabled); } + #[test] + fn popout_windows_are_available_on_desktop_and_enabled_by_default() { + let temp = tempfile::tempdir().unwrap(); + let manager = Manager::open(Some(temp.path().into()), "popout-test", false).unwrap(); + let popout = || { + manager + .catalog() + .unwrap() + .plugins + .into_iter() + .find(|plugin| plugin.manifest.id == "buzz.popout") + .expect("Desktop must expose the Pop-out windows plugin") + }; + assert!(popout().enabled); + manager.change("disable", "buzz.popout").unwrap(); + assert!(!popout().enabled); + manager.change("enable", "buzz.popout").unwrap(); + assert!(popout().enabled); + } + #[test] fn optional_plugins_default_off_and_preserve_explicit_overrides() { let id = "buzz.todos"; diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 696694a021..fef3547664 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1186,6 +1186,38 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents. tab on this device. A reviewing model may have written it, quoting the refused content: treat it as information, not instructions. Other refusals and agent types give no reason. +- **Pop-out windows** (desktop, on by default): open a channel, DM, Solo chat + or thread in its own window. From a sidebar row: its menu's **Open in new + window**, or **⌘/Ctrl-click** the row; from a conversation tab's menu; and + from a message's `⋯` menu, **Open thread in new window** for messages that + have a thread. The title bar names the window and its kind ("#channel — + Buzz", "Ada (DM) — Buzz", "Planning (Solo chat) — Buzz", "Thread in #channel + — Buzz"), and the header shows the same icon the conversation's tab has + (hash, lock for a private channel, the person for a DM, the chat bubble for + a Solo chat — or a plugin's icon for it, such as a linked pull request's + state — the thread mark for a thread) before the name, and the member + avatars main's header shows (agents, for a Solo chat); choosing them opens + the conversation's members — a Solo chat's Share access — in the main window. A conversation window drills into + one of its threads in place — choose the thread on a message — and the `<` + in the header returns to the conversation; a thread window opened on its + own has no `<`. Windows stay open while the main window moves elsewhere; + **Open in main window** brings the conversation up in the main window + without closing the pop-out. Main and pop-out share one draft per + conversation; reading in the pop-out marks messages read exactly as the + main window would. Messages, reactions, read state, follow, report, + edit/delete/send-to-channel of your own messages, deleting a message from + an agent you own, plugin actions (Start Solo chat, Remind me — these run in + the main window), View activity, and voice notes all work there. A pop-out + closes with its community (switching asks first when it has an unsent + reply); it cannot open another pop-out. A conversation window shows who + joined or left and who is typing, as the main window does, and an agent at + work appears as the same incoming-message placeholder with its current + step, **View activity** (opens in the main window) and **Stop**. Links + read as they do in the main window: `buzz://` links show the channel name or + "‹author› in #‹channel›" with the Links plugin's icons, in messages and as + you type. Solo chats show the conversation as messages; agent-specific surfaces (response + previews, stop controls) stay in the main window. Not available in the + browser build. - **Attention**, when available, lets agents use Interests, watches, and timers to follow work. Its tab in the profile of an agent running on this device shows when each interest wakes the agent (**Triggers**), its instructions after waking diff --git a/docs/huddles.md b/docs/huddles.md index 9b6f14f028..17eeb20ffc 100644 --- a/docs/huddles.md +++ b/docs/huddles.md @@ -169,6 +169,9 @@ draft in the main app even if the companion closes before acknowledgment. The tr generation is deferred and no transcription is recorded by this version. Both discussion surfaces use the shared message renderer for Markdown and attachments. +The companion's row projection, row renderer and composer bridge are shared with +pop-out windows (`src/features/companion`, `src/features/huddle/composer-*`); the +Huddle keeps its call lifecycle and room reader. The main owner supplies resolved media sources through the native presentation DTO; images open in the shared image stage without bypassing authenticated media. Native companion downloads are limited to source/name pairs in its current connected Huddle diff --git a/docs/images/popout/open-thread-menu.png b/docs/images/popout/open-thread-menu.png new file mode 100644 index 0000000000..2f295ca650 Binary files /dev/null and b/docs/images/popout/open-thread-menu.png differ diff --git a/docs/images/popout/solo-window.png b/docs/images/popout/solo-window.png new file mode 100644 index 0000000000..f4b21c4529 Binary files /dev/null and b/docs/images/popout/solo-window.png differ diff --git a/docs/images/popout/working-stop.png b/docs/images/popout/working-stop.png new file mode 100644 index 0000000000..dd5b26c2f0 Binary files /dev/null and b/docs/images/popout/working-stop.png differ diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 205752d58b..ed03840a87 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -2489,6 +2489,13 @@ card's edits and recovery stay its own; `threadId` keeps panels opened from a card in that thread. Writes, mark read/unread and follow remain explicit menu actions. +`conversation.registerTabAction({ id, title, matches(target, session), run(target, session) })` +adds an entry to a conversation tab's menu after the host's own actions (such +as Open in channel). `target` is `{ channelId, threadRootId? }`; the entry shows +only while `matches` passes for that tab, and disappears when the plugin is +disabled. A matcher that throws hides the entry. The bundled Pop-out windows +plugin uses it for **Open in new window** on thread tabs. + `conversation.ui.Tabs({ id, session, scope, label, icon, children })` makes the page's content the first main tab of the host's conversation tab workspace. `children(open, close, isOpen)` renders that tab; `open({ channelId, threadId?, messageId? }, { keep? })` diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md new file mode 100644 index 0000000000..4206516964 --- /dev/null +++ b/docs/popout-windows-design.md @@ -0,0 +1,894 @@ +# Pop-out conversation windows — design proposal + +Status: **S1 in progress** on `design/popout-window` (first runnable slice: +native host + bundled plugin + message-menu entry point; see §10). Base +`origin/main` 92d4c36. Authored by Jaw-fable51-pi for jaw. Revision 2 after +jaw-gpt56sol's adversarial review (findings 1–5 in §5 and §9 accepted; review at +`~/.buzz/RESEARCH/BUZZ_POPOUT_ADVERSARIAL_REVIEW_2026_11_03.md`). + +Requests: jaw ([buzz link](buzz://message?channel=ad8a406e-8554-4345-818d-44d824476fdc&id=617ac90f03732fb1026b19d4ac0cbba34ef3a34a953c9cb22feb5e8488a914b3&thread=ee272234fd46a925e151a44998a1d781108b9ac9b00b0c01507408654b9d5069)): +"sometimes I want a new window of a thread or a channel"; miread +([buzz link](buzz://message?channel=73572d23-0e3c-4d67-857f-75fdd6581dd7&id=b495b52662fd5e50d3a9cfa966e5e495bad7d6c06328be9f52f5088d0ac14060)): +"I use this feature so I don't lose a message that I need to respond to." +Prior feasibility note by jaw-gpt56sol: plugin API cannot open a trusted window; +this needs a desktop (Tauri) change. + +## 1. The Slack reference + +Slack desktop's **Open separate windows** — official help: + +(fetched 2026-11-03). Documented facts: + +| Aspect | Slack behavior (documented) | +| --- | --- | +| Purpose | "multi-task and keep up with a few channels or DMs at once" | +| Targets | Any channel or DM; threads; Activity and To-dos tabs; canvases | +| Channel/DM entry | **Cmd-click (Mac) / Ctrl-click (Win/Linux)** the conversation name in Home | +| Thread entry | Thread pane `⋯` → **Open in new window** | +| Activity/To-dos | Hover preview → window icon, or Cmd/Ctrl-click in sidebar | +| Count | "as many windows as you'd like", drag to arrange | + +Not documented by Slack (product-knowledge only, (?) = confirm in a client before +treating as a target): window contents are header + messages + composer without +sidebar/search; reopening the same target focuses the existing window (?); +main and pop-out can both show one conversation; drafts are shared because they +are server-synced; one read state; links inside a pop-out open in main (?); +closing main keeps pop-outs alive; pop-outs are not restored after restart (?). + +The *job* behind the feature (both requesters): keep one conversation visible and +answerable while the main window moves elsewhere. Reading, replying and +not-losing-the-message are the core; reactions, editing and nested threads are +secondary. + +## 2. Buzz today (evidence at 92d4c36) + +- **One multi-window precedent — the Huddle companion.** `src-tauri/src/huddle_window.rs` + ("A presentation-only companion. Microphone, identity and call ownership stay in + main."). Main calls `huddle_window_open(view, actions)`; the companion webview + (`huddle.html`, label `huddle`, capability `src-tauri/capabilities/huddle.json`: + "no identity, microphone or relay authority") calls `huddle_window_watch` and + sends `huddle_window_action`. Native validates every projected `View` with size + caps. The companion already projects a **conversation**: `Discussion { rows ≤200, + composer, status, writable, sending, has_more }` with actions `send | older | + retry | retryMessage | discardMessage | thread`, and it mounts the **real + `MessageComposer`** through a projected `ComposerSession` + (`src/features/huddle/composer-contract.ts`, `composer-owner.ts` 493 lines, + `composer-client.ts` 305 lines; `docs/huddles.md` "Conversation panels and + cards"). Uploads, membership checks, signing and outbox admission stay in main. +- **Main-only native authority.** Relay sockets, signing, identity, read-state + publication, deep links, notifications, dock and pairing commands check + `window.label() == "main"` (≈66 references in `src-tauri/src`; e.g. + `relay.rs:1969`, `deep_links.rs:73,154,166`, `notifications.rs:94,142`, + `dock.rs:25,44`). Native sockets are per-call and delivered to the calling + webview (`federated_identity/socket.rs:65`), capped at 16. +- **Main close semantics.** macOS: `CloseRequested` on `main` is prevented and + the window hidden — "Keep the webview and running agents alive until explicit + Quit" (`src-tauri/src/lib.rs:1100-1108`). Linux/Windows: main is destroyed; + `Destroyed` cancels spools/uploads (`lib.rs:1097-1098`). +- **Drafts.** Composer drafts are `localStorage` view-state keyed + `draft:[:thread:]` per scope (`src/shared/view-state.ts`). + `subscribeView` already listens to cross-window `storage` events, and + `replaceView` is documented as "Foreground comparison, not a cross-window + transaction or lock." Tauri webviews of one app share the origin's storage, so + two windows already see one draft. +- **Conversation addressing.** Every conversation (channel, DM, Solo chat, + thread) is addressable by `buzz://message?channel=&id=[&thread=]` + / `buzz://channel/` (`docs/deep-links.md`); in-app the Channels tab + workspace opens them via `open({ channelId, threadId?, messageId? })` + (`docs/plugin-architecture.md`, `conversation.ui.Tabs`). +- **UI surfaces that would carry the entry point.** Sidebar row context menu + (`useChannelRowMenu`, `ChannelSidebarRow`, Solo rows via `SoloDestination` + `ContextMenuRoot`), channel header *Channel actions* menu (`ChannelHeaderMenu`), + conversation tab strip menu (`PageTabs` tabMenu: Close / Close other / Close + all), thread pane header actions (`ThreadPanel` `parent` → "Open in channel"), + message action bar (`MessageActionBar`: Copy link …). +- **Governance.** `AGENTS.md`: scope checkpoint before code; one implementation + owner; 9/10 minimalness; FOUNDATION files need explicit human guidance; + cross-host changes name the host actually validated ("frontend checks do not + replace host signing or IPC enforcement"); new addressable surfaces follow + `docs/navigation-history.md`; update `docs/buzz-help.md`; PR readiness = agent + review → agent exercised in the **native app** → human tested → + `buzz-review-completed`; DCO sign-off on every commit. + +## 3. What parity means in Buzz + +**Targets.** Any conversation the viewer can already open in a Channels tab: +team channel, DM, Solo chat, and a thread of any of those. Not pages (Inbox, +Agents, Workflows), not Settings, not Canvas (separate request if wanted). + +**Entry points (parity = present wherever the conversation is named).** + +| Surface | Item | Notes | +| --- | --- | --- | +| Sidebar row right-click / `…` menu (channel, DM, Solo chat) | **Open in new window** | Same menu that holds Copy link / Mark read. Also **Cmd/Ctrl-click** the row, matching Slack's documented gesture (S2) | +| Conversation header *Channel actions* menu | **Open in new window** | | +| Conversation tab strip tab menu | **Open in new window** | Pops the tab's conversation or thread out; **the source tab stays open** (§5.1, §5.7) | +| Thread pane header | **Open in new window** icon (external-link glyph) next to the existing "Open in channel" | The primary thread path — the requesters' case | +| Message action bar / message `…` menu | **Open thread in new window** | Only on messages with a thread or as a reply target | +| macOS *Window* menu | one row per open pop-out | Free with Tauri window titles | +| Keyboard | none in S1; candidate `⌘⇧O` later | Avoid colliding with existing tab shortcuts (`⌘W`, `⌘⇧W`, `Ctrl+Tab`, `close_menu.rs`) | + +"Always in the contextual menu?" — Yes for conversation rows and tabs, since +that is where Slack users look first and it is cheap. On message rows it should +appear only when a thread exists, to keep the action bar from growing. + +**Pop-out window contents.** Title = conversation name (thread: "Thread — +#channel"). Header: name, member avatars count, *Open in main window* button. +Body: message list (same shared message renderer and bylines as main). Footer: +the full composer (rich text, attachments, mentions, emoji, GIF — as the Huddle +thread already has). Light/dark follows main (Huddle projects `dark`). No +sidebar, no page nav, no search, no Settings. + +**Behaviors.** + +1. Many pop-outs at once, one per distinct target. Opening an already-open + target focuses its window. +2. Main and pop-out may both show the target. Both live-update. One persisted + draft per target, shared, governed by the composer's **existing conflict + contract** (§5.1). Attachment drafts stay with the window that added them. +3. Reading in the pop-out earns unread marks through main's reading owner with + the same geometry/focus/dwell rules as a Channels tab (§5.6). Opening a + pop-out never marks anything read by itself. +4. Links to other conversations clicked inside a pop-out open in **main** and + focus it (`openLink` is forwarded as an action). `https` links open in the OS + browser (Huddle already has `opener:allow-open-url`). +5. *Open in main window* shows/focuses main and opens the target as a main tab; + **the pop-out stays open** (no move semantics in S1; see §5.2). +6. Closing main (macOS hide): pop-outs keep working because the owner renderer is + alive. Linux/Windows: closing main closes all pop-outs after flushing drafts + (S1); keeping them open there would require hide-on-close parity for main, + which is a separate product decision. +7. Community switch, sign-out, relay session replacement, losing membership or + channel archive: pop-outs for that scope close with a toast in main (same as + Huddle: "Disabling the plugin, losing channel access, replacing the relay + session, or switching community ends the local call"). +8. Window size/position remembered per target class (thread vs conversation); + pop-outs are not restored after restart in S1. +9. Browser build and mobile: no pop-out (no window API); the menu item is absent, + not disabled. + +## 4. Architecture options + +### A. Second full renderer (`index.html?window=popout&…`) with main capabilities + +Load the whole app in another webview, routed to one conversation. + +- Pro: every feature works on day one; smallest UI diff. +- Con: a **second `RelaySession`** per window → second socket and subscriptions + per pop-out (16-socket native cap), two read-state publishers, two outboxes on + shared storage, duplicated notifications/unread logic, Huddle/pairing/deep-link + code that assumes a single `main`. Requires opening signing/identity commands + to additional labels — expands the trusted surface the Huddle design + deliberately kept to one window. Reviewers will (rightly) challenge the + duplicated session semantics; this is the option most likely to be rejected + under the "clear ownership / no known material defects" bar. + +### B. Presentation companion (generalize the Huddle discussion) + +Main stays the only session, signer, outbox and read-state owner. A pop-out is a +`popout-` webview that watches a projected `ConversationView` and sends +actions back, exactly like the Huddle companion, with the Huddle `Discussion` +projection generalized from "a room's thread" to "any conversation target". + +- Pro: reuses ~1.9k lines of existing, reviewed projection code (composer + contract/owner/client, `discussion.ts`, `window.ts`, native validation, + `HuddleDiscussionView`); keeps the repo's security model unchanged; nothing new + gets signing authority; drafts and unread stay single-owner; main-close and + scope-change behavior inherit the Huddle lifecycle rules. +- Con: fidelity is bounded by what is projected. The Huddle row model has + text/attachments/mentions/edited/delivery but **no reactions, no thread + summaries, no message actions, no link previews/cards, no edit/delete**. Each + added feature is IPC surface plus native validation. Drift risk as main gains + features. + +### C. Thin renderer over a proxied session + +Run the real `ThreadPanel` / `ConversationTab` in the companion against a +`RelaySession` proxy: snapshot replication + forwarded operations for the +sub-services the conversation uses (the composer projection generalized to the +read side). + +- Pro: full UI fidelity by reusing components; single owner preserved. +- Con: `RelaySession` is a frozen object of ~50 sub-services + (`src/features/relay/session.ts:2153`); conversation components also call + native commands directly (media, clipboard, downloads) that are main-gated; + `useSyncExternalStore` consumers need synchronous replicated snapshots. This is + a new shared contract of real size — the kind of "speculative abstraction / + shared contract expansion" `AGENTS.md` asks to justify against a current + requirement. Reasonable **later**, if B's fidelity gap turns out to matter. + +### Recommendation: **B, sliced; keep C as the documented upgrade path.** + +B is the only option that is both consistent with the established +companion-window security model and small enough to pass the scope checkpoint. +It directly satisfies the stated jobs (watch, reply, don't lose the message). +Explicitly record the fidelity gap and the trigger for moving to C. + +## 5. Decisions on the reviewer's pre-stated criteria + +### 5.1 Same conversation open twice; who owns the draft + +One persisted draft per target, owned by **storage**, not a window. The composer +already implements the cross-editor contract and the pop-out runs the real +`MessageComposer`, so it inherits it unchanged: an external revision arriving +while the editor is dirty or a send is in flight sets **conflict**; local edits +are retained, sending is blocked until the user resolves (keep mine / load +theirs); an external revision arriving while clean is loaded +(`src/features/messages/MessageComposer.tsx:1247-1261`). `replaceView` is a +foreground comparison, not a lock — the conflict state is what makes that safe. +(Revision 1 proposed last-writer-wins and focus-based reload; withdrawn — it +contradicted the implementation.) Send clears the persisted draft from main only +if the stored revision still equals the one the sender saw +(`composer-owner.ts:280-287` pattern). **Open in new window leaves the source +tab open** (Slack semantics; also avoids §5.7). S1 tests: two editors typing on +one thread draft → conflict in the dirty one; send from A while B has a newer +unsaved draft → B keeps its text, A's stored-draft clear is skipped. + +### 5.2 Open in main window must not lose work + +Attachment drafts are local to the window that added them and Huddle releases +them on close; conflicted or storage-failed text can exist only in one editor. +So S1 has **no move**: *Open in main window* = show+focus main, open the target +tab, leave the pop-out as is. **Explicit close** of a pop-out (red button, +Cmd+W) runs the same guards as closing a tab, for all of: pending attachment +drafts, an unresolved draft conflict, text whose last persist **failed** +(`writeView` returned false), and pending operations in that window (send +awaiting admission, upload in progress) → confirm dialog naming what would be +lost. **Voluntary** exits that would retire pop-outs — community switch, +sign-out, Linux/Windows main Close — run the same guards first and can be +cancelled; a toast after the fact is not a guard. **Access revocation** +(membership loss, archive, relay session replaced by the server, plugin +disabled) retires immediately without prompting, because unauthorized context +must leave the screen; main toasts what closed. A later "move" variant needs an +acknowledged handoff that keeps the source until the destination exists. + +### 5.3 Which window owns signing / session actions; binding + +**Main, only.** Pop-outs get a capability modeled on `huddle.json`: `watch`, +`action`, `media-download` (restricted to sources in **that window's** current +projection — `media_download` today keys the check on the single `huddle` +label; it becomes per-label), `clipboard-write-text`, `opener:allow-open-url`. +No `relay-*`, `identity-*`, `sign-*`. Each window is bound at open to +`{ scope, viewer, channelId, threadRootId, sessionIncarnation }`; native stores +the binding with the session and rejects `watch`/`action`/`download` whose +caller label or binding doesn't match. Every action is re-validated in main +against current membership/writability before it reaches the outbox, as the +composer owner does today. Tests: two roots in one channel do not cross; an +action carrying a retired incarnation is dropped, not replayed. + +### 5.4 Huddle's "thread" is a room stream — S1 needs real thread semantics + +`HuddleDiscussion` streams a room's top-level messages: its composer has +`messages.reply: unavailable` (`composer-client.ts:246`), sends with top-level +`messages.send` and clears `draft:` (`composer-owner.ts:271-287`). A thread +pop-out instead needs: verified-root loading (the `ThreadPanel`/`useMessageOpening` +reader rules — cached rows decide nothing), `messages.reply` to the root, the +thread draft key `draft::thread:`, and admission bound to the +target (§5.3). So S1 reuses the **composer projection** (contract/owner/client) +and the window/validation plumbing, but the **read projection is new**: a +`ThreadView` produced from the existing thread reader, not `discussion.ts`. This +is the main reason the S1 estimate rose (§5.8). + +### 5.5 Main window closes + +- macOS: hidden, renderer alive → pop-outs keep working. Add: a pop-out's + *Open in main window* must `show()`+focus main (hidden main is the common case). +- Linux/Windows: destroying main destroys the owner. S1: on `CloseRequested` of + main with pop-outs open, run the §5.2 guards across them (a persist can have + failed; not every keystroke is necessarily in storage), then close pop-outs + first; cancelling keeps main open. Alternative (hide main on close, as macOS) is a separate + product decision; listed in §8. +- Renderer reload/crash of main: native keeps the last validated projection per + pop-out and bumps `sessionIncarnation`. The companion shows the cached + messages read-only with a "Buzz is reconnecting…" notice, its composer is + disabled, and any action carrying the old incarnation is rejected — **no send + replay**. On main startup S1 **retires** surviving pop-outs with an in-window + "Reopen from Buzz" notice (persisted drafts are already in storage); re-adopting + them is an S2/S3 item. (Huddle's `reset_incoming` only covers + incoming/connecting calls, so this is new, small native state — not inherited.) +- **Cmd+W**: `close_menu.rs` routes Close Tab to the focused *main* webview only. + With a pop-out focused, Cmd+W must close that pop-out (through §5.2 guards); + Cmd+Shift+W stays main-only. Linux/Windows have no app menu equivalent; + the window's own close control applies. + +### 5.6 Reading and unread parity + +Unread is earned, never granted: `docs/unread.md` requires a focused active +reading surface, visible document, settled positioning, fully visible rows and +300 ms dwell, with cancel/restart on scroll/content/focus change and manual +unread respected during dwell. The Huddle bridge has no reading path. S1 adds +one: the companion runs the same reading-observer module over its own geometry +and focus (window focused, `document.visibilityState`, rows fully visible, dwell) +and sends `observe(ids)` actions; main validates that the ids are in that +window's projection and feeds them to the session's reading owner, which holds +the lease. Main hidden (macOS) does not block it; a minimized/backgrounded +companion observes nothing; manual *Mark unread* in main during dwell wins. +Opening a pop-out marks nothing. Observations carry the companion's focus/ +visibility generation and the incarnation; main drops an `observe` whose +generation predates a blur, hide, or a newer manual *Mark unread* — **delayed IPC +must not resurrect cancelled reading intent**. Acceptance includes that case. +Shipping without this path (**S1a**) is a product tradeoff — unread stays lit +for messages read in a pop-out — for jaw to accept explicitly, not a +line-budget shortcut. + +### 5.7 Navigation history + +A pop-out is a native window, not an addressable dialog or tab, so it creates no +history visit. Because the source tab stays open (§5.1) no existing content +history changes; *Open in main window* opens an ordinary tab visit. Failed +opens (window creation error) leave the source exactly as it was and toast in +main. + +### 5.8 Community or scope changes + +Projection carries `scope`. Voluntary switches (community, sign-out) run the +§5.2 guards and may be cancelled; revocations (relay-session replacement, +membership loss, channel archive) retire the affected pop-outs immediately +(`update(id, null)` → native `destroy()`) with one toast in main naming what +closed. Huddle's rule set plus the voluntary/revoked distinction. + +### 5.9 Initial slice vs full parity + +| Slice | Content | Approx. production diff (excl. tests/docs) | +| --- | --- | --- | +| **S1 — Thread pop-out** | Entry points: thread pane header, tab menu, message `…` (when a thread exists). Native: `popout_window_open/update/watch/action` generalized from Huddle with `HashMap`, per-window binding + incarnation (§5.3), per-label download gate, Cmd+W routing. Renderer: `popout.html` entry; new `ThreadView` read projection from the thread reader (§5.4); composer projection reused with `reply` + thread draft key; `HuddleDiscussionView`/`HuddleComposer` moved to a shared owner; reading observations (§5.6, or S1b). Actions: send/older/retry/discard/openLink/openInMain/observe/closed. Focus-existing on reopen. Close guards (§5.2). | ~1,300–1,800 lines (native 400–550, thread projection + owner 400–600, composer generalization 150–250, UI 250–300, menus/shortcut 100) — above the 1k mark; split S1a (window+read-only+composer) / S1b (reading) if review asks | +| **S2 — Channel / DM / Solo chat pop-out** | Same window, target without thread root: top-level stream, day dividers, "Reply in thread" action that opens the thread in **main** (or in another pop-out), typing indicator, members count. Sidebar row and header menu entry points. | ~400–700 | +| **S3 — Fidelity** | Reactions (read + add), edit/delete own messages, link previews/cards, read-marker. Each as its own small PR; at this point evaluate Option C instead of adding more projection fields. | per item 150–400 | + +Non-goals for S1–S2: canvases, pages, Settings, restoring pop-outs after +restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. + +### 5.10 Validation + +- Native: wire encoding/validation tests beside the Huddle ones + (`huddle_window.rs` tests); label-gating tests for the new capability. +- Renderer: Vitest for the projection reducer and menu items; browser journey + only for what lower layers cannot prove (none expected for S1; the companion is + a native window). +- **Native app checks are mandatory**: open → type → send → close; reopen focuses; + main hidden on macOS then *Open in main window*; community switch retires; + dark/light, narrow width, enlarged text; keyboard focus order in the pop-out. + Browser screenshots do not validate window lifecycle or IPC gating. + +## 6. Getting it merged (per `AGENTS.md` / `docs/contributing.md`) + +1. **Justification on record.** This doc plus the two user requests; the Huddle + precedent shows the pattern is already accepted. Frame S1 as "generalize the + existing companion to conversation threads", not "add multi-window". +2. **Scope checkpoint before code** (publish in the PR/issue): required behavior + (§3 S1 rows), non-goals (§5.9), owners reused (`huddle_window.rs`, + `composer-owner/client`, `discussion.ts`, `HuddleDiscussionView`, + `ContextMenu`/`MenuItem`, `PageTabs` tabMenu, `ThreadPanel` header), expected + files, diff budget (§5.9). +3. **FOUNDATION check.** None of the files named in §5.5 carry a `FOUNDATION` + marker at 92d4c36 (`huddle_window.rs`, `lib.rs`, `capabilities/*`, + `features/huddle/*`, `bundled/huddles/*`, `ThreadPanel.tsx`, `PageTabs.tsx`); + 15 files in the repo do — re-grep when the file list is final. +4. **Design-system rules.** Reuse `MenuItem`, `Button`, `Panel` recipes; name the + DESIGN.md sections in the checkpoint; check both themes, narrow width, + enlarged text, keyboard/pointer. +5. **Navigation contract.** A pop-out is a native window, not an addressable + dialog/tab, so it creates no history visit; *Open in main window* opens a + normal tab visit. State this in the PR so reviewers don't expect history + coverage. +6. **Cross-host statement.** Desktop-only; browser build hides the entry points; + validation host = macOS native app (name the OS/arch actually used). +7. **Docs.** `docs/buzz-help.md` (user-facing), `docs/huddles.md` cross-reference + if the discussion projection moves to a shared owner, this doc updated from + proposal to implemented behavior. +8. **PR flow.** Draft PR → agent review (necessity + correctness, 9/10 bar) → + agent exercises in the native app → jaw tests with exact steps → add + `buzz-review-completed`. Every commit `--signoff`; Co-authored-by trailer for + the agent. +9. **Reviewer pre-reads** (`AGENTS.md` Reviewing): block/buzz `VISION*.md` + for the conversation surface, `docs/huddles.md`, `docs/shell-design.md`, + `docs/navigation-history.md`. + +## 7. Risks + +- **Scope.** S1 is now ~1.3–1.8k production lines; the honest comparison is "a + second Huddle-sized feature", not a menu item. Mitigation: S1a/S1b split; ship + read-only+compose first. + +- **Fidelity expectation.** Users may expect reactions/threads in the pop-out + immediately. Mitigation: S1 header shows *Open in main window* prominently; + release note names what the pop-out does and does not do. +- **Projection drift.** Mitigation: one shared owner module for the row DTO used + by both Huddle and pop-outs so a renderer change updates both. +- **Linux/Windows main-close.** Pop-outs close with main; acceptable for S1 but + weaker than Slack. +- **Tauri capability globbing** for `popout-*` labels: confirmed supported in the + pinned tauri 2.12.4 / tauri-utils 2.9.3 (`acl/capability.rs:150,164`, "Can be a + glob pattern"). + +## 8. Open questions for jaw + +1. Slack details marked (?) in §1 — confirm from your client: focus-existing on + reopen; links inside a pop-out open in main; pop-outs not restored on restart. +2. Reviewer-settled: Open in new window keeps the source tab; Open in main keeps + the pop-out. Any objection? +3. Linux/Windows: accept "closing main closes pop-outs" for S1? +4. Is S1 = threads only acceptable as the first PR, with channels/DMs/Solo in + S2? If S1 runs long, do you accept **S1a** — pop-outs that read and reply but + do **not** clear unread (unread stays lit for messages you read there) — + as an interim product state, with S1b following? +5. Does this belong in `block/buzz-app` core (recommended; needs native) or + should the entry points also be exposed to plugins later (`ctx.windows.open`)? + Core first; plugin API only on demand. +6. Window title/branding for pop-outs ("Thread — #channel — Buzz"?), and + whether the pop-out should show the member avatar stack or only the name. + +### F1 — reactions and core menu in the companion (2026-11-05) + +jaw's live tryout accepted startup/compose and asked for reactions and +"normal menus", with containment (a thread pop-out never spawns another). +Boundary (reviewer-corrected): the companion stays a projection; reactions go +through the existing composer bridge as bounded main-owned operations +(`react`, `removeReactions`) admitted against the rows the window shows and +main's own view of their reaction events; the companion's reaction outbox +(`companion/reactions.ts`) shows its own not-yet-admitted requests and, +projected through the view, main's admitted-but-undelivered/failed reaction +events (`reactionOperations`), so Retry retries the exact admitted event; +main fences one reaction change per message through delivery; own events are +capped at the 100 `messages.remove` accepts; +`MessageReactions`/`ReactionTool`/`ReactionToolProps` are narrowed to the +`ComposerSession`-based subset they use (type-only; `RelaySession` still +satisfies them); `MessageRow` gains a `reactionSlot`. Rows project reactions +with the viewer's events first so a capped list still toggles correctly. +Copy text / Copy link (clipboard) and reply-to-message work; edit, delete, +read-state, report and plugin-contributed actions remain **gaps** (they need +real admission paths, not menu labels). No spawn operation exists for a +thread companion. + +## 9. Review log + +- 2026-11-03 jaw-gpt56sol adversarial review at `dae45a5` (7/10 readiness). + Findings accepted and folded in: (1) draft policy → existing conflict contract + (§5.1); (2) Open in main as focus-not-move, close guards (§5.2); (3) Huddle + thread is a room stream → new thread read projection, binding and incarnation + (§5.3–5.4); (4) unread parity via main-owned reading leases (§5.6); (5) history + untouched by keeping the source tab open (§5.7). Evidence corrections: Slack + official help cited (§1); `reset_incoming` not generalizable, reload recovery + specified; Cmd+W routing (§5.5). Estimate raised accordingly (§5.9). +- 2026-11-03 second pass at `4cf951e`: 1, 3, 5 addressed; 4 stated. Finding 2 + extended: close guards also cover storage-failed text and pending operations; + voluntary switch/close is cancellable, revocation is immediate (§5.2, §5.5, + §5.8); delayed-IPC reading observations must not revive cancelled intent + (§5.6); S1a framed as an explicit product tradeoff (§8 Q2/Q4). +- 2026-11-04 implementation reviews (`~/.buzz/RESEARCH/BUZZ_POPOUT_S1_CODE_REVIEW_2026_11_04.md`): + `c7e840a` → R1 reading durability, R2 verified-root gating, R3 creation/scope + race, R5 external links, R6 image copy fixed at `2c1d407`; R1 delayed-acquire + ordering and a `main_closing` partial-cancellation regression fixed at + `2467aed`. go 2 (`01d040c`) and go 1 (`164fcc4`) reviewed; three exit + bypasses (Settings + cross-community navigation, `joined`, organization + sign-out) fixed at `9b4e1e9`; stale-session sign-out confirmation fixed at + `75ce01e`. Reviewer: all reported findings closed at source/mounted-test + level; S1 awaits native/human acceptance. Full vitest suite at `75ce01e`: + 897 files / 15,998 tests green. Production diff vs checkpoint ≈ +2,830/−310 + (≈260 moved from Huddle), above the 2,050 upper bound; the overage is the + authorized exit-guard and tab-action host work (~550 lines) the checkpoint + listed as owners to reuse rather than build. + +- 2026-11-05 menu parity (F2) reviews: groups 1–2 (read state, follow, + report, own edit/delete/send-to-channel, verified agent-owner delete) closed + at `a5d8664`; group 3 (plugin actions, View activity) closed at `16f794b` + after three rounds: handoff is an action-opening request in main bound to + the exact contribution instance and the originating session object (not + scope string), menu contributions activated through their own item inside + the requesting bar's popup, activity panel opened only after the navigation + reported `opened` in the same session, native row bounds enforced in the + projection. Group 4 (voice): recorder tool offered to the pop-out host only + (never the Huddle), microphone permission extended to `popout-*` labels on + the app origin (non-macOS hook), active capture counted as unsent work for + the close guard, contribution removal cancels the capture. Native acceptance + of F1–F2 by jaw remains open. + +## 10. S1 scope checkpoint (per `AGENTS.md` "Engineering standard") + +Approved by jaw 2026-11-04 (design, parity, approach B, slicing, core-only, +platform-natural main-close). Roles: **jaw** — accountable contributor and +implementation owner (commits author as jaw); **Jaw-fable51-pi** — +implementation agent (`Co-authored-by` trailer); **jaw-gpt56sol** — independent +reviewer of the first working slice; maintainer review/merge per CODEOWNERS +(`@block/buzz-oss-team`). Slack dedup/link-routing/restore behavior remains +unverified (jaw's grouped "yes" is not treated as tested evidence); S1 keeps +"no restart restoration" as an approved non-goal regardless. + +### Required behavior (S1 — thread pop-out, desktop only) + +1. **Open in new window** for a thread from: thread pane header action + (beside the existing `parent` "Open in channel" link), conversation tab-strip + tab menu for thread tabs, message `⋯` on messages that have a thread. + Source tab/pane stays open. Browser build and non-desktop hosts hide the item. +2. A `popout-` native window titled from the thread's channel, showing + header (title, *Open in main window*), the thread rows (root + replies, same + shared message renderer/bylines/attachments/mentions as Huddle discussion) + with *Older* paging, and the real `MessageComposer` replying to the root. +3. Opening an already-open target focuses its window instead of creating one. +4. Main stays the only relay session, signer, outbox and read-state owner. The + window is natively bound to `{scope, viewer, channelId, threadRootId, + sessionIncarnation}`; `watch`/`action`/`media-download` are gated per label + and binding; stale-incarnation actions are dropped, never replayed. +5. Drafts: thread draft key `draft::thread:`, shared with main + through the composer's existing conflict contract; send clears the stored + draft only on revision equality. +6. *Open in main window*: show+focus main, open the thread tab there, pop-out + stays. Explicit close (red button, Cmd+W when the pop-out is focused) guards + pending attachment drafts, unresolved conflict, storage-failed text, pending + send/upload with a confirm dialog. +7. Lifecycle: voluntary community switch / sign-out / Linux-Windows main Close + run the guards and are cancellable; revocation (membership loss, archive, + server-replaced session, Huddle-style plugin disable) retires immediately + with a main toast. macOS main close hides main; pop-outs keep working. Main + renderer reload: companion goes read-only, composer disabled, retire on main + startup with "Reopen from Buzz". +8. Reading is **in S1** (reviewer recommendation; jaw delegated slicing): + companion sends `observe(ids)` with focus/visibility generation under + `docs/unread.md` rules; main validates against the window's projection and + feeds its reading owner; delayed or pre-blur observations are dropped; + opening marks nothing. Intermediate draft commits may land the window before + the reading bridge, but the PR is not ready for review without it. +9. Links: Buzz links → `openLink` action, handled in main (focus main); + `http(s)` → OS browser via `opener`. +10. Light/dark follow main; keyboard focus order and enlarged text checked. + +### Deviations recorded during implementation + +- Entry points shipped: message `⋯` → **Open thread in new window** (works on + the channel timeline and inside the thread pane, on the root and replies). + The thread-pane header icon and tab-strip menu item are **deferred**: the + thread header is not rendered in the tab workspace, and a tab-menu item would + couple the Channels plugin to the pop-out owner through `PanelWorkspace`'s + `tabMenu` contract. Follow-up if the menu path proves insufficient. +- Voluntary exits (authorized FOUNDATION edit, jaw 2026-11-04): the + communities service owns **exit holds** (`holdExit(describe, subscribe?)`, + `exitHolds()`); `select(id, { force?, after? })` returns false and records a + `pendingSelection` while holds exist; `confirmSelection`/`cancelSelection` + settle it and `after` runs only once selected. The shell (`App.tsx`) shows + one "Switch community?" dialog for a pending selection and, through the + existing single-slot departure guard (composed with unsaved Settings), + for a navigation that would change community — before its route commits; + Keep working preserves route and community. Navigation-driven selection + (`navigation.ts`) passes `force` because the guard already asked. Both the Buzz sign-out + confirmation and the organization sign-out (a confirmation while holds + exist) list the same holds; `communities.joined` keeps the admitted + membership but leaves selection pending while holds exist; a + cross-community navigation with both unsaved Settings and held work shows + the one community prompt naming both. The pop-out plugin registers its + held windows via `ctx.communityReader.holdExit`. Access revocation still + retires immediately. +- Tab-menu **Open in new window** via `conversation.registerTabAction` + (authorized FOUNDATION edit): shown after "Open in channel" for thread tabs. +- Reading bridge is a request/response BroadcastChannel (`reading-bridge.ts`) + with durability-complete acknowledgments and an event-time revision fence + on acquisition (`readingRevision` in the view). +- Notices surface through a null-rendering composer accessory that claims each + notice once (host toasts need the `ToastProvider` context). Smallest hook + found; replace if a host notice API appears. +- Pop-out is a bundled plugin (`buzz.popout`, default on) mirroring Huddles, + so Settings → Plugins can disable it. + +### Non-goals (S1) + +Channels/DMs/Solo pop-outs and Cmd/Ctrl-click (S2); reactions, edit/delete, +link previews/cards, read marker in the pop-out (S3); restoring pop-outs after +restart; re-adopting pop-outs after main reload; Linux/Windows hide-on-close for +main; keyboard shortcut to pop out; plugin API (`ctx.windows.*`); canvases, +pages, Settings; mobile/browser hosts; window title/avatar polish (discuss +after first native tryout, per jaw). + +### Existing owners and platform support reused + +- Native companion plumbing and validation: `src-tauri/src/huddle_window.rs` + (`open/update/watch/action`, `closed_notice`, size-capped `validate`), + `src-tauri/capabilities/huddle.json` as the capability template, + `media_download` label gate (`relay.rs:1958-1975`), `close_menu.rs` for Cmd+W, + `lib.rs` main-close handling (`:1096-1108`). +- Composer projection: `src/features/huddle/composer-contract.ts`, + `composer-owner.ts`, `composer-client.ts` (add `reply`, thread draft key). +- Thread reading: `session.thread(channelId, rootId, …)` + (`src/features/relay/session.ts:2483`), `ThreadPanel` `parent`/`headerActions` + (`ThreadPanel.tsx:81-152`), `useMessageOpening` verified-root rules. +- Reading: `src/features/messages/use-reading.ts` (`useReading`, + `readingPositioned`) and `docs/unread.md` contract. +- Sharing rule: extract from Huddle only where the contract is identical + (composer bridge, native window open/update/watch/action, validation); + Huddle call/audio lifecycle and ordinary thread reading stay with their + owners; no general window framework. +- Window bridge and companion entry: `src/features/huddle/window.ts`, + `src/bundled/huddles/window-entry.tsx`, `HuddleDiscussionView.tsx`, + `HuddleComposer.tsx`, `huddle.html` + Vite input as the template for + `popout.html`. +- Menus: `MenuItem`/`ContextMenu*` recipes; `PageTabs` `tabMenu`; + `MessageActionBar`. Design guide sections: menus/popovers (nested surfaces), + dialog (close-guard confirm), buttons. +- Drafts: `src/shared/view-state.ts` (`replaceView`, `subscribeView`). + +### Expected files + +New: `popout.html`, `src/bundled/popout/*` or `src/features/popout/*` +(window bridge, `ThreadView` projection, companion entry/view, tests), +`src-tauri/src/popout_window.rs` (+ tests), `src-tauri/capabilities/popout.json`. +Modified: `src-tauri/src/lib.rs` (register commands, close handling), +`src-tauri/src/relay.rs` (`media_download` gate), `src-tauri/src/close_menu.rs`, +`vite.config.ts` (second HTML input, as `huddle.html`), `src/features/huddle/composer-*.ts` +(generalize room → target; keep Huddle behavior), `ThreadPanel.tsx` +(header action), `PageTabs.tsx`/`ConversationTab.tsx` (tab menu item), +`MessageActionBar.tsx`, `docs/buzz-help.md`, `docs/huddles.md` cross-ref, this doc. + +### Diff budget (production, excluding tests/docs) + +| Area | Lines | +| --- | --- | +| Native (`popout_window.rs`, capability, gates, Cmd+W, close handling) | 400–550 | +| `ThreadView` projection + owner (verified root, paging, retire/incarnation) | 400–600 | +| Composer projection generalization (`reply`, thread draft key, binding) | 150–250 | +| Companion UI (entry, view, header, guards) | 250–300 | +| Entry points (3 menus/actions) | ~100 | +| Reading bridge | 150–250 | +| **Total** | **1,450–2,050** | + +Stop-and-show rule: if production diff exceeds the upper bound or a new shared +contract beyond the ones listed becomes necessary, stop and post the smallest +alternative before continuing. + +### Validation plan + +- Vitest: projection reducer, binding/incarnation rejection, draft-key + derivation, menu item presence by host; RTL for companion view and guards. +- Native Rust tests: DTO validation caps, label/binding gating, per-label + download gate, two roots one channel, stale incarnation dropped. +- Native app (macOS, name the arch/OS build in the PR): open → type → send → + close; focus-existing; two editors conflict; Open in main with main hidden; + community switch cancel/confirm; membership revocation retire; main reload + read-only; Cmd+W in pop-out; dark/light, narrow, enlarged text, keyboard. +- Not claimed: Linux/Windows native runs unless executed; browser journeys add + nothing here and are not planned. + +## 11. S2 scope checkpoint — channel, DM and Solo pop-outs (2026-11-05) + +Requested by jaw in-thread ("please continue … implement the channel and chat +pop outs"; Slack screenshots of a channel pop-out drilling into a thread with +a `<` back control). Same PR as S1 by owner decision. + +### Required behavior + +- **Open a conversation in a new window**: channel, DM or Solo chat, from the + sidebar row menu ("Open in new window") and by **⌘/Ctrl-click** on the row. + One window per conversation per connection (reopen focuses it). +- **Title and header** as Slack: title bar "#channel — Buzz" / "Ada — Buzz"; + thread frames "Thread in #channel — Buzz"; header names what is shown. +- **In-window drill-down**: a row's thread affordance ("N replies" / reply in + thread) opens that thread **in the same window** with a `<` back control + that returns to the conversation (hover-coloured). The window keeps a small + back stack; a thread pop-out opened from a message's `⋯` has no stack and + still never spawns windows. +- **Parity** for the conversation frame = everything the thread frame has + (reply, reactions, menu actions, handoffs, voice, read state) over the + channel timeline, plus day dividers. Draft per conversation shared with main. +- **Reading**: the conversation frame claims the channel's shown rows; thread + frames claim theirs (existing reading owner, per frame). + +### Approach + +The companion model is unchanged. A window is a stack of **frames**; each +frame is the existing `(projection, composer owner, reading owner)` triple for +one target. `createPopoutThread` generalizes to a conversation projection: +with a root id its source is `session.thread`; without, `session.channels. +window(channelId)` (top-level rows, membership/huddle rows hidden). New +companion actions `openThread ` (push a thread frame for a shown row) +and `back` (pop). Native: title from the view (`thread` flag), no new commands. +Entry points reuse the sidebar row menu and the row click handler. Solo chats: +the timeline projection only; agent reply surfaces specific to Solo are not +re-created in the companion (named as a gap in help). + +### Review log (S2) + +- `6ba910c` → four findings: conversation frames hit thread-shaped owner + admission (actions, handoff, retry) and the Huddle roster rule on send; + files kept in a hidden frame were lost on back (drafts keyed by the + companion's rebuilt session objects); hidden-frame reading leases could + still mark read. Fixed `b1f8d70`: admission gates on the frame's projection + (`shown`), pop-out frames trust relay writability, the companion retains + unmounted editors' drafts and transfers them back, leases re-check + enablement per request and are revoked when a frame is covered. +- `b1f8d70` → the retained-draft cap evicted silently; an active voice + capture was discarded by drill/back. Fixed `f4e2e24`: no eviction; the + companion asks to discard a recording before navigating. Reviewer: all S2 + findings closed at source/test level. Native acceptance open. + +- Follow-up requested by jaw: typing indicator and join/leave notice rows in + conversation windows. Main projects `typing` (two human names + count, + agent names; from `session.typing`/`agentActivity` with the same pure + classification as `TypingIndicator`) and keeps membership rows in the + timeline projection; the companion renders `MembershipRow` groups and a + text status line in main's words. Agent response previews and stop + controls stay in main. +- jaw's tryout: a pasted `buzz://message` link rendered as a raw URL in a + pop-out's rows and as plain text in its editor, where main shows "‹author› + in #‹channel›" / a chip with the Links plugin's icon. Two causes: the + companion had no `links` renderers (its extensions carried only + tools/completions/inline), and its rows had no reference directory (no + channel list; and the byline needs a `RelaySession` to read the linked + message, which a companion never has). Fix: main projects enabled link + renderer keys in the composer snapshot (`links`), the companion table maps + `buzz.links/links` to the plugin's exported `linkRenderer`; `CompanionRows` + passes the bridge session as `references` (channel names, Solo chips) and + the thread projection adds `linkLabels` per row, resolved in main through + the same shared `linkedAuthors` lookups and `session.names` the main + byline uses (bounded: 64 links per window, released as links leave the + view; native DTO `link_labels` ≤64 × ≤1000 bytes). + +- jaw's tryout: the sidebar row's "Open in new window" had no icon and sat + in a different place than the message menu's "Open thread in new window" + (Solo rows put contributed actions at the top, channel rows after Copy + link). `TabAction.icon?: ComponentType` (FOUNDATION contract, same shape as + `MessageAction.icon`) rendered in a `MenuIcon` by both inventories; the + Solo menu takes contributed actions as a `conversationActions` slot right + after Copy link, matching the channel row's menu. Tab menus stay text-only + like their host items. + +- jaw's tryout: a dark pop-out flashed light while opening. The companion + page had no appearance bootstrap (white document until React applied the + projected appearance) and the native window used the platform default + background. Fix: `popout.html`/`huddle.html` load `appearance-init.js` + + `startup.css` like `index.html` (saved mode paints first; main's projection + then takes over), and the native builder sets `background_color` from the + opening view's appearance (`--bg-app`: dark `--neutral-2` `#101010`, light + `--neutral-quiet-hover` `#f1f1f2`; a test resolves the alias chain per mode + from `tokens.css`). Reviewer caught the first cut using the dark hover + step. Also added `popout.json` to the command-capability test's inputs, + which had been failing on `popout_window_watch` since the capability file + was introduced. + +- jaw: a pop-out's kind (channel / private / DM / Solo / thread) wasn't + legible — DM and Solo both showed a bare name. Main projects `kind` and a + DM's first person's `picture`; the header shows the tab strip's icon + (`channelIcon` rules; `MessageReplyIcon` for thread frames) with an + accessible kind label, and names a DM by its people (`dmLabel`, as tabs and + Inbox do) instead of the relay channel name. Native title carries the kind + where no icon can: `#design`, `leads` (private), `Ada (DM)`, `Planning + (Solo chat)`, each as `Thread in …` for thread frames. Not done: plugin + chat icons for Solo (contribution not available in the companion) and the + community name. + +### Non-goals (S2) + +Canvas/pages/Settings windows; restoring windows after restart; member +counts in the companion; agent response previews/stop controls in the +companion; multiple frames of history beyond conversation → thread (one +level is what Slack shows); mobile/browser. + +### Diff budget (production, excluding tests/docs) + +Projection generalization 100–150; frames/navigation in `window.ts` 150–250; +companion UI (timeline layout, dividers, `<` back, header) 100–200; entry +points 60–120; native title/DTO 20–40. **Total ≈ 450–750.** Same +stop-and-show rule as §10. + +## Screenshots (jaw's tryout, 2026-11-10) + +![A Solo chat open in its own window beside the main window: kind icon, title "Assess local execution feedback (Solo chat) — Buzz", the same messages and composer](images/popout/solo-window.png) + +![A message's ⋯ menu with "Open thread in new window" after Follow thread](images/popout/open-thread-menu.png) + +![An agent's live work on a request: "Starting" with a Stop control](images/popout/working-stop.png) + +## 12. S3 scope checkpoint — completeness for a self-contained PR (2026-11-09) + +jaw, after his step-3 tryout passed: the PR ships as a complete feature, so +three documented gaps close before it leaves draft. + +### Required behavior + +1. **Member count (header identity).** The pop-out header shows what main's + `ChannelHeaderIdentity` shows: the member avatar stack (up to 3, `+N` + overflow; agents for Solo, people otherwise; the Members/Agents glyph when + none) before the name. Choosing it opens the conversation in the main + window with its access dialog (`panel: "members"` for channels and DMs; + Solo: main shows the chat and its header opens **Share access** through a + session-fenced request, `soloAccessRequests`, the way message actions are + handed off — jaw's tryout found the first cut only opened the chat). Main projects + `members: { count, preview[≤3]: { id, name, picture, agent }, solo }`, + bounded in the native DTO; names resolve through `session.names` as main's + stack does; pictures through `avatarMedia`. New companion action + `openMembers` (native focuses main, forwards like `openInMain`). +2. **Agent response previews and Stop.** While an agent works in the + frame's conversation, the pop-out shows the same incoming-message + placeholder main shows (`AgentResponsePreview`: name, picture, other + working agents as participants, the current step text and waiting/working + state) in the timeline position main gives it, with a **Stop** per + stoppable request. Main projects `working[≤8]: { agent, name, picture, + state, step?, stops[≤8]: { key, label } }` from the same sources + `TypingIndicator` reads (`agentActivity` + `conversationWorkStops`). + Stop is a new composer-bridge operation `stop { key }`: the owner + re-derives the frame's current stop controls at admission and runs the + matching `interrupt()`; an unknown or finished key is refused with a + message; the companion shows "Stopping…" until the next projection drops + the control or the request fails. The "Activity" link opens the agent's + activity in main via the existing View-activity handoff. The status line + from S2 stays for humans typing; agents move into the preview. +3. **Plugin chat icons in the header (Solo).** The sidebar lets a plugin + replace a Solo chat's icon (`registerChatIcon`; GitHub shows the linked + pull request's state). Plugin components never run in the companion, so + the contribution needs a serializable form: **proposed FOUNDATION edit** + (`conversation/service.tsx`): `ChatIcon.describe?(session, channelId): + { icon: , tone?: string, label: string } | + undefined`, read by main's projection; the companion maps the name to the + same icon. GitHub's `SoloPullIcon` gains `describe`. Without jaw's + go-ahead on the contract edit, this slice is deferred and documented. + +### Review log (S3) + +- Members `aa5054f` → two P2 (overflow count from a capped placeholder list; + only the previewed three profiles requested). Fixed `64f06a5`: `AvatarStack` + takes `total`; the bounded roster is enriched before filtering as main's + header does. Reviewer-closed. +- Previews + Stop: `typing.working[≤8]` projected from `currentActivity` + + `conversationWorkStops` (the same helpers `TypingIndicator` uses); Stop is + the composer-bridge op `stop {key}`, admitted by the top frame's + `stopWork(key)` which re-derives the controls at that moment and refuses a + key whose work ended. Companion `CompanionWorking` renders + `AgentResponsePreview` + `ActivityStepPreview` + one `AgentActivityRow` per + agent with Stop; humans keep the status line, the full sentence stays for + assistive technology. `fc2f479` → two P2: conversation frames excluded + thread work (main's channel indicator includes it; only rows with their + own live bubble take it over, which companion rows never have), and a + hosted Stop could pend forever. Fixed: `includeThreads` for conversation + frames, no root subtraction; the bridge answers `{lifecycle, running}` and + the companion drives main's own `useWorkStops` (toasts, 30 s retry window, + "nothing running"), controls carrying `lifecycle`. + +- Plugin chat icons: jaw approved the FOUNDATION edit. `ChatIcon.describer?: + { describe(session, channelId) → { icon: ChatIconName, tone?, label } | + undefined; watch(session, channelId, listener) }`, registered as + `registerChatIcon(component, describer?)` (validated); `ChatIconName` is a + closed set drawn by `conversation/chat-icon-catalog.tsx`. GitHub's icon + describes itself from `SoloPulls`. The projection shows a Solo chat's first + describing contribution (the sidebar shows the first component), watches + each describer for the chat while the frame lives, and drops watches for + contributions that leave. Native `View.icon {icon ≤64 token, tone?, label}`. + The companion draws the catalog artwork with the sidebar's tone colours and + names it "Solo chat: ‹label›". `c95e657` → three P2 (a later describing + plugin could win over the sidebar's first; a Solo→channel conversion kept + its watch; `chatIconFor("constructor")` hit the catalog prototype). Fixed: + only the first contribution counts, watches reconcile on every projection, + own-property lookup; merged tone purple as in the sidebar. + +- jaw: the Solo conversation header's `⋯` (Chat actions) lacked "Open in new + window" (the channel header's menu already shared the sidebar's builder). + `SoloHeading` takes `tabActions` and renders the shared + `ConversationActionItems` after Copy link; `SoloInventory` uses the same + component, so every Solo menu lists the action identically. + +- jaw's tryout (hosted agent): main showed "Running cat, head, git… · Stop" + in the **request row's live bubble** while its footer indicator for the + same agent only knew typing; the pop-out projected only the footer. Fix: + rows a live turn or local request names as trigger get `live[≤8]` + (`requestWork` on that message → agent, step/status from the local step or + the live turns' in-progress tools, Stop via `workStop` with the row as + scope); `CompanionRows` renders `CompanionWorking` under that row; the + footer excludes agents a row presents, as main's `presented` does; + `stopWork(key)` admits row controls too. Native `DiscussionRow.live`. + +- jaw's tryout (channel pop-out, request made in a thread): no bubble/Stop + — the request reply is not a channel row. Main's channel view shows a + thread's live work on its **root row**; `requestsFor(row)` now maps live + requests (local work `threadRootId`, turn `requests`/`threadRootId`) to the + shown root in conversation frames, Stop scoped to the exact request + (`requestScope`). Also: the companion document could scroll past its + content (a transient overflow left the header off-screen); `html`, `body` + and `#root` are now pinned to the viewport with `overflow: hidden` in both + companion entries — only the message list scrolls. + +### Non-goals (S3) + +Members dialog *inside* the pop-out (it stays main's); starting/stopping +agents other than Stop on live work; plugin chat icons for non-Solo rows; +restoring windows after restart; community name in header/title. + +### Diff budget (production, excluding tests/docs) + +Members 80–140; previews + Stop 200–320 (projection 80, owner op 40, native +40, companion 120); chat icons 60–100. **Total ≈ 350–560.** Same +stop-and-show rule as §10. diff --git a/huddle.html b/huddle.html index 08df77cab4..9f9e546a26 100644 --- a/huddle.html +++ b/huddle.html @@ -1,2 +1,14 @@ -Huddle — Buzz
+ + + + + + + Huddle — Buzz + + +
+ + + diff --git a/popout.html b/popout.html new file mode 100644 index 0000000000..ccf9b1c165 --- /dev/null +++ b/popout.html @@ -0,0 +1,16 @@ + + + + + + + + + Buzz + + +
+ + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index e89efd798e..2f15281075 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -60,6 +60,11 @@ fn main() { "huddle_window_update", "huddle_window_watch", "huddle_window_action", + "popout_window_open", + "popout_window_update", + "popout_window_detached", + "popout_window_watch", + "popout_window_action", "huddle_open", "huddle_close", "huddle_pcm", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 213ca5c72d..d11262c484 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,9 @@ "allow-huddle-window-reset-incoming", "allow-huddle-window-open", "allow-huddle-window-update", + "allow-popout-window-open", + "allow-popout-window-update", + "allow-popout-window-detached", "allow-huddle-open", "allow-huddle-close", "allow-huddle-pcm", diff --git a/src-tauri/capabilities/popout.json b/src-tauri/capabilities/popout.json new file mode 100644 index 0000000000..2b81882df4 --- /dev/null +++ b/src-tauri/capabilities/popout.json @@ -0,0 +1,23 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "popout-window", + "description": "Presentation-only pop-out conversation windows; no identity, relay or signing authority.", + "webviews": ["popout-*"], + "permissions": [ + "allow-popout-window-watch", + "allow-popout-window-action", + "allow-media-download", + "allow-clipboard-write-text", + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "https://*" + }, + { + "url": "http://*" + } + ] + } + ] +} diff --git a/src-tauri/src/close_menu.rs b/src-tauri/src/close_menu.rs index 417500a13d..2e7ec24d59 100644 --- a/src-tauri/src/close_menu.rs +++ b/src-tauri/src/close_menu.rs @@ -108,6 +108,11 @@ pub(crate) fn menu(app: &AppHandle) -> tauri::Result> { } pub(crate) fn handle(app: &AppHandle, event: tauri::menu::MenuEvent) { + // A focused pop-out window is the key window: Close closes it, through + // its unsaved-work guard, rather than a tab in the hidden main window. + if event.id().as_ref() == CLOSE && crate::popout_window::close_focused(app) { + return; + } let script = match event.id().as_ref() { CLOSE => format!("{{ const tabEvent = \"buzz:close-active-tab\";\n{REQUEST}}}"), CLOSE_ALL => format!("{{ const tabEvent = \"buzz:close-all-tabs\";\n{REQUEST}}}"), diff --git a/src-tauri/src/huddle_window.rs b/src-tauri/src/huddle_window.rs index 51d3a94f37..4e3c4ed7c0 100644 --- a/src-tauri/src/huddle_window.rs +++ b/src-tauri/src/huddle_window.rs @@ -16,6 +16,9 @@ pub(crate) struct View { muted: bool, level: f32, dark: bool, + /// The main window's applied appearance (mode, accent, contrast, text scale). + #[serde(default)] + appearance: Option, participants: Vec, #[serde(default)] audio_settings: Option, @@ -57,13 +60,14 @@ struct Discussion { has_more: bool, rows: Vec, } +/// One projected message. Shared with pop-out windows, which present the same rows. #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct DiscussionRow { +pub(crate) struct DiscussionRow { id: String, author_id: String, channel_id: String, - attachments: Vec, + pub(crate) attachments: Vec, author: String, picture: Option, #[serde(default)] @@ -73,6 +77,9 @@ struct DiscussionRow { mentions: Vec, #[serde(default)] mention_names: std::collections::BTreeMap, + /// Labels main resolved for message links in `text`, by URL (pop-outs). + #[serde(default)] + pub(crate) link_labels: std::collections::BTreeMap, #[serde(default)] edited: bool, #[serde(default)] @@ -81,22 +88,123 @@ struct DiscussionRow { mention_references: Vec, time: u64, delivery: String, + /// Reaction groups, bounded; a pop-out shows and toggles them. Huddle + /// rows omit them. + #[serde(default)] + pub(crate) reactions: Vec, + #[serde(default)] + reactions_limited: bool, + /// What the viewer may do to this message, decided by main (pop-outs). + #[serde(default)] + management: Option, + /// Edit baseline for messages the viewer may edit (pop-outs). + #[serde(default)] + editable: Option, + /// The author is an agent, by profile hint (pop-outs). + #[serde(default)] + author_is_agent: bool, + /// Replies in this message's thread (pop-out conversation frames). + #[serde(default)] + reply_count: u32, + /// Relay-authored membership activity shown as a notice row (pop-outs). + #[serde(default)] + membership: Option, + /// Plugin actions main would offer, for a labelled main-window handoff. + #[serde(default)] + actions: Vec, + /// Agents at work on this request, shown under the row (pop-outs). + #[serde(default)] + live: Vec, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Membership { + #[serde(rename = "type")] + kind: String, + actor: String, + target: String, +} +impl Membership { + fn valid(&self) -> bool { + let hex = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()); + matches!( + self.kind.as_str(), + "member_joined" | "member_left" | "member_removed" + ) && hex(&self.actor) + && hex(&self.target) + } +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct HandoffAction { + key: String, + revision: String, + title: String, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Management { + unread: bool, + can_edit: bool, + can_delete: bool, + can_send_to_channel: bool, + can_report: bool, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Editable { + source_content: String, + attachment_source_id: String, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReactionGroup { + content: String, + #[serde(default)] + emoji: Option, + events: Vec, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReactionEmoji { + shortcode: String, + url: String, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct DiscussionAttachment { +pub(crate) struct ReactionEvent { + id: String, + author_id: String, +} +impl ReactionGroup { + fn valid(&self) -> bool { + let hex = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()); + // 64 code points of text, or a `:shortcode:` token; bytes bound both. + (self.content.chars().count() <= 66 && self.content.len() <= 512) + && self + .emoji + .as_ref() + .is_none_or(|e| e.shortcode.len() <= 64 && e.url.len() <= 8192) + && !self.events.is_empty() + && self.events.len() <= 128 + && self.events.iter().all(|e| hex(&e.id) && hex(&e.author_id)) + } +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DiscussionAttachment { url: String, kind: String, mime: Option, size: Option, - name: Option, + pub(crate) name: Option, duration: Option, voice_note: Option, waveform: Option>, dimensions: Option, blurhash: Option, preview_url: Option, - source: Option, + pub(crate) source: Option, preview_source: Option, } #[derive(Clone, Deserialize, Serialize)] @@ -105,6 +213,40 @@ struct AttachmentDimensions { width: u32, height: u32, } +impl DiscussionRow { + pub(crate) fn valid(&self) -> bool { + self.id.len() <= 128 + && self.author_id.len() <= 128 + && self.channel_id.len() <= 128 + && self.attachments.iter().all(|a| a.valid()) + && matches!( + self.delivery.as_str(), + "sending" | "accepted" | "unknown" | "failed" | "seen" + ) + && self.author.len() <= 4096 + && self.text.len() <= 65536 + && self.picture.as_ref().is_none_or(|p| p.len() <= 8192) + && self.picture_url.as_ref().is_none_or(|p| p.len() <= 8192) + && self.reactions.len() <= 64 + && self.reactions.iter().all(|r| r.valid()) + && self.editable.as_ref().is_none_or(|e| { + e.source_content.len() <= 65536 && e.attachment_source_id.len() <= 128 + }) + && self.membership.as_ref().is_none_or(|m| m.valid()) + && self.link_labels.len() <= 64 + && self + .link_labels + .iter() + .all(|(url, label)| url.len() <= 8192 && label.len() <= 1000) + && self.live.len() <= 8 + && self.live.iter().all(|w| w.valid()) + && self.actions.len() <= 32 + && self + .actions + .iter() + .all(|a| a.key.len() <= 256 && a.revision.len() <= 256 && a.title.len() <= 256) + } +} impl DiscussionAttachment { fn valid(&self) -> bool { matches!(self.kind.as_str(), "image" | "video" | "audio" | "file") @@ -123,6 +265,52 @@ impl DiscussionAttachment { .is_none_or(|duration| duration.is_finite() && (0.0..=86400.0).contains(&duration)) } } +/// Appearance attributes a companion mirrors onto its document; bounded tokens. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Appearance { + color_mode: String, + #[serde(default)] + accent: Option, + #[serde(default)] + color_level: Option, + #[serde(default)] + font_scale: Option, +} +impl Appearance { + fn light() -> Self { + Self::mode(false) + } + /// A bare appearance for a view that reports only `dark`; the one source + /// of the native background colours. + pub(crate) fn mode(dark: bool) -> Self { + Self { + color_mode: if dark { "dark" } else { "light" }.into(), + accent: None, + color_level: None, + font_scale: None, + } + } + /// The window's native background before its page paints: `--bg-app` of the + /// applied color mode (dark: `--neutral-2`; light: `--neutral-quiet-hover`), + /// so a dark window never flashes light while its document loads. Pinned to + /// the design-system tokens by a test in `popout_window.rs`. + pub(crate) fn background(&self) -> tauri::window::Color { + if self.color_mode == "dark" { + tauri::window::Color(0x10, 0x10, 0x10, 0xff) + } else { + tauri::window::Color(0xf1, 0xf1, 0xf2, 0xff) + } + } + pub(crate) fn valid(&self) -> bool { + let token = + |s: &String| s.len() <= 32 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'); + matches!(self.color_mode.as_str(), "light" | "dark") + && self.accent.as_ref().is_none_or(token) + && self.color_level.as_ref().is_none_or(token) + && self.font_scale.is_none_or(|f| (0.5..=4.0).contains(&f)) + } +} #[derive(Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Person { @@ -207,6 +395,7 @@ fn validate(view: &View) -> Result<()> { ) || !view.level.is_finite() || !(0.0..=1.0).contains(&view.level) + || view.appearance.as_ref().is_some_and(|a| !a.valid()) || view.participants.len() > 256 || view.audio_settings.as_ref().is_some_and(|a| { a.inputs.len() > 128 @@ -226,20 +415,7 @@ fn validate(view: &View) -> Result<()> { || !matches!(d.status.as_str(), "loading" | "ready" | "error") || d.error.as_ref().is_some_and(|e| e.len() > 8192) || d.rows.len() > 200 - || d.rows.iter().any(|r| { - r.id.len() > 128 - || r.author_id.len() > 128 - || r.channel_id.len() > 128 - || r.attachments.iter().any(|a| !a.valid()) - || !matches!( - r.delivery.as_str(), - "sending" | "accepted" | "unknown" | "failed" | "seen" - ) - || r.author.len() > 4096 - || r.text.len() > 65536 - || r.picture.as_ref().is_some_and(|p| p.len() > 8192) - || r.picture_url.as_ref().is_some_and(|p| p.len() > 8192) - }) + || d.rows.iter().any(|r| !r.valid()) }) || view.participants.iter().any(|p| { !p.level.is_finite() @@ -309,6 +485,11 @@ pub(crate) async fn huddle_window_open( validate(&view)?; let incoming = view.phase == "incoming"; let id = view.id.clone(); + let background = view + .appearance + .as_ref() + .map(Appearance::background) + .unwrap_or_else(|| Appearance::light().background()); let on_closed = closed_notice(actions.clone(), id.clone()); { let mut slot = host.0.lock().map_err(|_| "Huddle window unavailable")?; @@ -335,6 +516,7 @@ pub(crate) async fn huddle_window_open( .min_inner_size(360.0, 400.0) .center() .disable_drag_drop_handler() + .background_color(background) .build() .map_err(|e| e.to_string())?, }; @@ -535,6 +717,7 @@ mod tests { muted: false, level: 0.0, dark: false, + appearance: None, participants: vec![], discussion: None, discussion_open: false, @@ -626,11 +809,21 @@ mod tests { text: "Hello".into(), mentions: vec!["cd".repeat(32)], mention_names: [("cd".repeat(32), "Taylor".into())].into(), + link_labels: Default::default(), edited: false, attachment_seams: vec![3], mention_references: vec!["ef".repeat(32)], time: 1, delivery: "unknown".into(), + reactions: vec![], + reactions_limited: false, + management: None, + editable: None, + author_is_agent: false, + reply_count: 0, + membership: None, + actions: vec![], + live: vec![], }], }); let encoded = serde_json::to_value(&presentation).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49286782fa..19df847e47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,10 +26,15 @@ mod decisions; mod deep_links; mod dock; mod huddle_window; +mod popout_window; use huddle_window::{ huddle_window_action, huddle_window_open, huddle_window_reset_incoming, huddle_window_update, huddle_window_watch, HuddleWindow, }; +use popout_window::{ + popout_window_action, popout_window_detached, popout_window_open, popout_window_update, + popout_window_watch, Popouts, +}; #[cfg(test)] #[path = "enterprise_adapter_url.rs"] mod enterprise_adapter_url; @@ -693,6 +698,11 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen huddle_window_update, huddle_window_watch, huddle_window_action, + popout_window_open, + popout_window_update, + popout_window_detached, + popout_window_watch, + popout_window_action, huddle_open, huddle_close, huddle_pcm, @@ -1169,6 +1179,7 @@ pub fn run() { .manage(pairing::Pairing::default()) .manage(Huddles::default()) .manage(HuddleWindow::default()) + .manage(Popouts::default()) .manage(relay::Uploads::default()) .register_asynchronous_uri_scheme_protocol("buzz-media", relay::media_protocol) .manage(Imports::default()) @@ -1232,10 +1243,21 @@ pub fn run() { if let Some(credentials) = webview.try_state::() { credentials.page_load(); } + popout_window::detach_all(webview.app_handle()); } browser::page_load(webview, payload); }) .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if popout_window::close_requested(window, api) { return; } + // macOS hides main instead (below), so companions keep their owner. + #[cfg(not(target_os = "macos"))] + if window.label() == "main" && popout_window::main_closing(window.app_handle(), api) { return; } + } + if matches!(event, tauri::WindowEvent::Destroyed) { + popout_window::destroyed(window); + if window.label() == "main" { popout_window::main_destroyed(window.app_handle()); } + } if window.label() == "main" && matches!(event, tauri::WindowEvent::Destroyed | tauri::WindowEvent::CloseRequested { .. }) { window.state::().cancel_all(); } if window.label() == "main" && matches!(event, tauri::WindowEvent::Destroyed) { window.state::().cancel_all(&window.state::()); } #[cfg(target_os = "macos")] @@ -1374,6 +1396,7 @@ mod tests { include_str!("../capabilities/default.json"), include_str!("../capabilities/desktop-window.json"), include_str!("../capabilities/huddle.json"), + include_str!("../capabilities/popout.json"), ] .concat(); let commands = handlers["generate_handler![".len()..] diff --git a/src-tauri/src/microphone.rs b/src-tauri/src/microphone.rs index 23171b75b2..cf7327788e 100644 --- a/src-tauri/src/microphone.rs +++ b/src-tauri/src/microphone.rs @@ -2,7 +2,9 @@ //! //! macOS keeps Wry's default grant and the TCC prompt. Elsewhere an unhandled //! request is denied (WebKitGTK) or prompts for `tauri.localhost` (WebView2). -//! Capture belongs to the main app page; the Huddle companion never captures. +//! Capture belongs to the main app page and to pop-out windows showing it +//! (voice notes record where the composer is); the Huddle companion never +//! captures. use tauri::webview::{PermissionKind, PermissionResponse}; use url::Url; @@ -24,7 +26,7 @@ pub(crate) fn decide( ("tauri", Some("localhost")) | ("http" | "https", Some("tauri.localhost")) ) || development.is_some_and(|development| development.origin() == page.origin()) }); - if label == "main" && app_page { + if (label == "main" || crate::popout_window::is_popout(label)) && app_page { PermissionResponse::Allow } else { // Deny rather than defer so WebView2 cannot offer its own prompt. @@ -37,7 +39,7 @@ mod tests { use super::*; #[test] - fn only_the_main_app_page_gets_the_microphone() { + fn only_the_app_page_in_main_or_a_pop_out_gets_the_microphone() { use PermissionResponse::{Allow, Default, Deny}; let development = Url::parse("http://localhost:1430").unwrap(); let cases = [ @@ -49,6 +51,11 @@ mod tests { ("main", "https://example.org/", Deny), ("main", "http://tauri.localhost.example.org/", Deny), ("huddle", "tauri://localhost/huddle.html", Deny), + ("popout-1234", "tauri://localhost/popout.html", Allow), + ("popout-1234", "http://tauri.localhost/popout.html", Allow), + ("popout-1234", "http://localhost:1430/popout.html", Allow), + ("popout-1234", "https://example.org/", Deny), + ("popout", "tauri://localhost/popout.html", Deny), ]; for (label, page, expected) in cases { let page = Url::parse(page).unwrap(); diff --git a/src-tauri/src/popout_window.rs b/src-tauri/src/popout_window.rs new file mode 100644 index 0000000000..1703a0979c --- /dev/null +++ b/src-tauri/src/popout_window.rs @@ -0,0 +1,1204 @@ +//! Pop-out conversation windows: presentation-only companions of the main +//! window, as the Huddle window is. Identity, relay, signing, the outbox and +//! read state stay in main; each companion receives one bounded `View` and +//! sends bounded actions back. A window is bound to one conversation target in +//! one session incarnation; watch, action and download calls are checked +//! against the caller's own label and binding. +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; +use tauri::{ipc::Channel, Manager, WebviewWindow}; + +use crate::huddle_window::DiscussionRow; + +type Result = std::result::Result; +const PREFIX: &str = "popout-"; + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Binding { + scope: String, + viewer: String, + channel_id: String, + /// The thread root; a whole conversation (S2) leaves it empty. + #[serde(default)] + thread_root_id: Option, + /// Owner session incarnation. Actions from an earlier incarnation are + /// dropped: a reloaded main renderer never replays a stale send. + incarnation: u64, +} +impl Binding { + fn target(&self) -> (String, String, String, Option) { + ( + self.scope.clone(), + self.viewer.clone(), + self.channel_id.clone(), + self.thread_root_id.clone(), + ) + } + fn valid(&self) -> bool { + let hex = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()); + self.scope.len() <= 4096 + && !self.scope.is_empty() + && hex(&self.viewer) + && !self.channel_id.is_empty() + && self.channel_id.len() <= 256 + && self.thread_root_id.as_deref().is_none_or(hex) + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct View { + title: String, + /// The owner's persistence scope, for message links the companion copies. + #[serde(default)] + scope: String, + dark: bool, + /// The main window's applied appearance (mode, accent, contrast, text scale). + #[serde(default)] + appearance: Option, + status: String, + #[serde(default)] + error: Option, + writable: bool, + can_load_more: bool, + limited: bool, + loading_older: bool, + /// Reading bridge token: main publishes reading intent for this window. + #[serde(default)] + reading: Option, + reading_revision: u64, + #[serde(default)] + composer: Option, + version: u64, + /// Native-owned: increments when the OS asked to close while the companion + /// holds unsaved work, so it can confirm. Never set by main. + #[serde(default)] + close_requested: u64, + /// Native-owned: the owner is gone (main reload); the companion shows its + /// cached rows read-only until main retires or re-adopts it. + #[serde(default)] + detached: bool, + #[serde(default)] + root: Option, + replies: Vec, + /// Main's outbox operations in this channel: reactions, removals, edits + /// and sent-to-channel copies, in every delivery state. + #[serde(default)] + operations: Vec, + /// The viewer follows this thread. + #[serde(default)] + following: bool, + /// A DM or Solo chat: no `#`, no thread following offered. + #[serde(default)] + is_dm: bool, + /// What kind of conversation: `channel`, `private`, `dm` or `solo`. + #[serde(default = "default_kind")] + kind: String, + /// A DM's other person's picture (authorized media source). + #[serde(default)] + picture: Option, + /// The conversation's members as main's header previews them. + #[serde(default)] + members: Option, + /// A plugin's icon for a Solo chat, in data. + #[serde(default)] + icon: Option, + /// A thread (root + replies) rather than a conversation timeline. + #[serde(default)] + thread: bool, + /// The frame was opened from the previous one; `back` returns there. + #[serde(default)] + can_go_back: bool, + /// People typing and agents working here, as names. + #[serde(default)] + typing: Option, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Typing { + humans: Vec, + more: u32, + agents: Vec, + /// Agents at work, as main's response preview presents them. + #[serde(default)] + working: Vec, +} +impl Typing { + fn valid(&self) -> bool { + self.humans.len() <= 2 + && self.agents.len() <= 8 + && self + .humans + .iter() + .chain(&self.agents) + .all(|n| n.len() <= 1024) + && self.working.len() <= 8 + && self.working.iter().all(|w| w.valid()) + } +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct WorkingAgent { + id: String, + name: String, + picture: Option, + state: String, + step: String, + status: String, + #[serde(default)] + scope: Option, + #[serde(default)] + activity: Option, + #[serde(default)] + stops: Vec, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct WorkStop { + key: String, + label: String, + #[serde(default)] + lifecycle: bool, + #[serde(default)] + status: Option, +} +impl WorkingAgent { + pub(crate) fn valid(&self) -> bool { + let hex = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()); + hex(&self.id) + && self.name.len() <= 1024 + && self.picture.as_ref().is_none_or(|p| p.len() <= 8192) + && matches!(self.state.as_str(), "waiting" | "working") + && self.step.len() <= 2048 + && self.status.len() <= 4096 + && self.scope.as_ref().is_none_or(|s| s.len() <= 4096) + && self.activity.as_ref().is_none_or(|a| a.len() <= 8192) + && self.stops.len() <= 8 + && self.stops.iter().all(|s| { + s.key.len() <= 512 + && s.label.len() <= 4096 + && s.status.as_ref().is_none_or(|t| t.len() <= 4096) + }) + } +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MainOperation { + id: String, + #[serde(default)] + message_id: Option, + kind: u32, + delivery: String, + #[serde(default)] + error: Option, +} +impl MainOperation { + fn valid(&self) -> bool { + let hex = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()); + hex(&self.id) + && self.message_id.as_deref().is_none_or(hex) + && matches!(self.kind, 7 | 5 | 40003 | 9) + && matches!( + self.delivery.as_str(), + "sending" | "accepted" | "unknown" | "failed" | "seen" + ) + && self.error.as_ref().is_none_or(|e| e.len() <= 2048) + } +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Action { + OpenLink, + OpenInMain, + /// Open the conversation's members/access in the main window. + OpenMembers, + /// Bring the main window forward after a handoff admitted through the + /// composer bridge; nothing is forwarded to main. + FocusMain, + /// A conversation window drills into one of its shown threads (text: root id). + OpenThread, + /// Return to the frame this one was opened from. + Back, + Older, + Retry, + RetryMessage, + DiscardMessage, + /// Companion holds unsaved/pending work (text "1") or not ("0"); native + /// close requests then ask before destroying the window. + Guard, + /// Companion confirmed a guarded close. + Close, + Closed, +} +#[derive(Clone, Serialize)] +pub(crate) struct Control { + label: String, + incarnation: u64, + action: Action, + text: Option, +} +struct Session { + binding: Binding, + view: View, + guarded: bool, + actions: Channel, + updates: Option>, +} +#[derive(Default)] +pub(crate) struct Popouts(Mutex>); + +impl Popouts { + /// Downloads are limited to attachment sources in this window's own projection. + pub(crate) fn permits_download(&self, label: &str, source: &str, name: &str) -> bool { + self.0.lock().ok().is_some_and(|map| { + map.get(label).is_some_and(|session| { + session + .view + .root + .iter() + .chain(&session.view.replies) + .flat_map(|row| &row.attachments) + .any(|attachment| { + attachment.source.as_deref() == Some(source) + && attachment.name.as_deref().unwrap_or("") == name + }) + }) + }) + } +} + +pub(crate) fn is_popout(label: &str) -> bool { + label.starts_with(PREFIX) +} +fn require_main(window: &WebviewWindow) -> Result<()> { + if window.label() == "main" { + Ok(()) + } else { + Err("Only the main window owns pop-out windows".into()) + } +} +fn require_popout(window: &WebviewWindow) -> Result { + if is_popout(window.label()) { + Ok(window.label().to_owned()) + } else { + Err("Not a pop-out window".into()) + } +} +impl View { + /// The OS title bar names what the window is, as Slack does: "Thread in + /// #channel" (a DM has no `#`). + /// Names the conversation and its kind where no icon can: "#design", + /// "Ada (DM)", "Planning (Solo chat)"; a thread frame says so first. + fn window_title(&self) -> String { + let conversation = match self.kind.as_str() { + "dm" => format!("{} (DM)", self.title), + "solo" => format!("{} (Solo chat)", self.title), + "private" => self.title.clone(), + _ => format!("#{}", self.title), + }; + if self.thread { + format!("Thread in {conversation} — Buzz") + } else { + format!("{conversation} — Buzz") + } + } +} +fn default_kind() -> String { + "channel".into() +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ChatIcon { + icon: String, + #[serde(default)] + tone: Option, + label: String, +} +impl ChatIcon { + fn valid(&self) -> bool { + let token = |s: &String| { + !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') + }; + token(&self.icon) && self.tone.as_ref().is_none_or(token) && self.label.len() <= 4096 + } +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Members { + count: u64, + solo: bool, + preview: Vec, +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct MemberPreview { + id: String, + name: String, + picture: Option, +} +impl Members { + fn valid(&self) -> bool { + self.count <= 1_000_000 + && self.preview.len() <= 3 + && self.preview.iter().all(|p| { + p.id.len() <= 128 + && p.name.len() <= 4096 + && p.picture.as_ref().is_none_or(|s| s.len() <= 8192) + }) + } +} +fn validate(view: &View) -> Result<()> { + if view.title.len() > 4096 + || !matches!(view.kind.as_str(), "channel" | "private" | "dm" | "solo") + || view.picture.as_ref().is_some_and(|p| p.len() > 8192) + || view.members.as_ref().is_some_and(|m| !m.valid()) + || view.icon.as_ref().is_some_and(|i| !i.valid()) + || view.scope.len() > 4096 + || view.appearance.as_ref().is_some_and(|a| !a.valid()) + || view.typing.as_ref().is_some_and(|t| !t.valid()) + || view.error.as_ref().is_some_and(|e| e.len() > 8192) + || !matches!(view.status.as_str(), "loading" | "ready" | "error") + || [&view.composer, &view.reading].iter().any(|t| { + t.as_ref() + .is_some_and(|t| uuid::Uuid::parse_str(t).is_err()) + }) + || view.replies.len() > 500 + || view.operations.len() > 64 + || view.operations.iter().any(|op| !op.valid()) + || view + .root + .iter() + .chain(&view.replies) + .any(|row| !row.valid()) + { + return Err("Invalid pop-out presentation".into()); + } + Ok(()) +} +fn closed_notice( + actions: Channel, + label: String, + incarnation: u64, +) -> impl Fn(&tauri::WindowEvent) + Send + Sync + 'static { + let actions = Mutex::new(Some(actions)); + move |event| { + if !matches!(event, tauri::WindowEvent::Destroyed) { + return; + } + let Some(actions) = actions.lock().ok().and_then(|mut slot| slot.take()) else { + return; + }; + let label = label.clone(); + // Channel send AND final drop evaluate JavaScript. Neither may reenter + // Wry while it destroys its window map under a mutable RefCell borrow. + tauri::async_runtime::spawn_blocking(move || { + let _ = actions.send(Control { + label, + incarnation, + action: Action::Closed, + text: None, + }); + }); + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Opened { + label: String, + /// The target already had a window; it was focused and keeps its owner. + existing: bool, +} + +/// Open or focus the window for a target. A binding that is already open keeps +/// its current session; the caller then updates that label instead. +#[tauri::command] +pub(crate) async fn popout_window_open( + window: WebviewWindow, + host: tauri::State<'_, Popouts>, + binding: Binding, + view: View, + actions: Channel, +) -> Result { + require_main(&window)?; + if !binding.valid() { + return Err("Invalid pop-out target".into()); + } + validate(&view)?; + // Painted before the page: a dark window opens dark, not white. + let background = view + .appearance + .as_ref() + .map(crate::huddle_window::Appearance::background) + .unwrap_or_else(|| crate::huddle_window::Appearance::mode(view.dark).background()); + let app = window.app_handle(); + let label = { + let mut map = host.0.lock().map_err(|_| "Pop-out windows unavailable")?; + if map.len() >= 16 { + return Err("Too many pop-out windows are open".into()); + } + let target = binding.target(); + if let Some((label, _)) = map.iter().find(|(_, s)| s.binding.target() == target) { + let label = label.clone(); + if let Some(existing) = app.get_webview_window(&label) { + existing.show().map_err(|e| e.to_string())?; + existing.set_focus().map_err(|e| e.to_string())?; + return Ok(Opened { + label, + existing: true, + }); + } + // The window is gone but its session lingered: replace it. + map.remove(&label); + } + let label = format!("{PREFIX}{}", uuid::Uuid::new_v4()); + map.insert( + label.clone(), + Session { + binding: binding.clone(), + view: View { + close_requested: 0, + detached: false, + ..view + }, + guarded: false, + actions: actions.clone(), + updates: None, + }, + ); + label + }; + let on_closed = closed_notice(actions, label.clone(), binding.incarnation); + let built = + tauri::WebviewWindowBuilder::new(app, &label, tauri::WebviewUrl::App("popout.html".into())) + .title("Buzz") + .inner_size(520.0, 640.0) + .min_inner_size(360.0, 400.0) + .center() + .disable_drag_drop_handler() + .background_color(background) + .build(); + let companion = match built { + Ok(companion) => companion, + Err(error) => { + if let Ok(mut map) = host.0.lock() { + map.remove(&label); + } + return Err(error.to_string()); + } + }; + companion.on_window_event(on_closed); + companion.show().map_err(|e| e.to_string())?; + companion.set_focus().map_err(|e| e.to_string())?; + Ok(Opened { + label, + existing: false, + }) +} + +/// Replace a window's presentation; `None` retires the window. +#[tauri::command] +pub(crate) fn popout_window_update( + window: WebviewWindow, + host: tauri::State<'_, Popouts>, + label: String, + view: Option, +) -> Result<()> { + require_main(&window)?; + if let Some(view) = &view { + validate(view)?; + } + let mut map = host.0.lock().map_err(|_| "Pop-out windows unavailable")?; + let Some(session) = map.get_mut(&label) else { + return Ok(()); + }; + if let Some(view) = view { + let next = View { + close_requested: session.view.close_requested, + detached: false, + ..view + }; + if let Some(updates) = &session.updates { + let _ = updates.send(next.clone()); + } + session.view = next; + if let Some(companion) = window.app_handle().get_webview_window(&label) { + let _ = companion.set_title(&session.view.window_title()); + } + } else { + map.remove(&label); + drop(map); + if let Some(companion) = window.app_handle().get_webview_window(&label) { + companion.destroy().map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// The main renderer is reloading or gone: every companion keeps its cached +/// rows but can no longer act. Main retires or re-adopts them when it returns. +pub(crate) fn detach_all(app: &tauri::AppHandle) { + let Some(host) = app.try_state::() else { + return; + }; + let Ok(mut map) = host.0.lock() else { + return; + }; + for session in map.values_mut() { + if session.view.detached { + continue; + } + session.view.detached = true; + session.view.composer = None; + session.view.writable = false; + if let Some(updates) = &session.updates { + let _ = updates.send(session.view.clone()); + } + } +} + +/// Labels of windows opened before the main renderer reloaded. +#[tauri::command] +pub(crate) fn popout_window_detached( + window: WebviewWindow, + host: tauri::State<'_, Popouts>, +) -> Result> { + require_main(&window)?; + let map = host.0.lock().map_err(|_| "Pop-out windows unavailable")?; + Ok(map + .iter() + .filter(|(_, s)| s.view.detached) + .map(|(label, _)| label.clone()) + .collect()) +} + +#[tauri::command] +pub(crate) fn popout_window_watch( + window: WebviewWindow, + host: tauri::State<'_, Popouts>, + updates: Channel, +) -> Result<()> { + let label = require_popout(&window)?; + let mut map = host.0.lock().map_err(|_| "Pop-out windows unavailable")?; + let session = map + .get_mut(&label) + .ok_or("This pop-out window has closed")?; + updates + .send(session.view.clone()) + .map_err(|e| e.to_string())?; + session.updates = Some(updates); + Ok(()) +} + +#[tauri::command] +pub(crate) fn popout_window_action( + window: WebviewWindow, + host: tauri::State<'_, Popouts>, + action: Action, + text: Option, +) -> Result<()> { + let label = require_popout(&window)?; + match action { + Action::Closed => return Err("Window closure is native-owned".into()), + Action::RetryMessage | Action::DiscardMessage => { + if text + .as_ref() + .is_none_or(|t| t.trim().is_empty() || t.len() > 65536) + { + return Err("Invalid pop-out message".into()); + } + } + Action::OpenLink => { + if text.as_ref().is_none_or(|url| { + !url.get(..5) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("buzz:")) + || url.len() > 8192 + }) { + return Err("Invalid pop-out link".into()); + } + } + Action::Guard => { + if !matches!(text.as_deref(), Some("0") | Some("1")) { + return Err("Invalid pop-out guard".into()); + } + } + Action::OpenThread => { + if !text + .as_deref() + .is_some_and(|id| id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err("Invalid pop-out thread".into()); + } + } + _ => { + if text.is_some() { + return Err("Unexpected pop-out message".into()); + } + } + } + let app = window.app_handle(); + let mut map = host.0.lock().map_err(|_| "Pop-out windows unavailable")?; + let session = map + .get_mut(&label) + .ok_or("This pop-out window has closed")?; + match action { + // Native keeps the close guard; main also hears it, so a voluntary + // community exit can name the window's unsent work before discarding it. + Action::Guard => session.guarded = text.as_deref() == Some("1"), + Action::Close => { + drop(map); + if let Some(companion) = app.get_webview_window(&label) { + companion.destroy().map_err(|e| e.to_string())?; + } + return Ok(()); + } + // All continue in the main window, which may be hidden on macOS. + Action::OpenInMain | Action::OpenMembers | Action::OpenLink | Action::FocusMain => { + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + if action == Action::FocusMain { + return Ok(()); + } + } + _ => {} + } + if session.view.detached { + return Err("Buzz is reconnecting. Try again from the main window.".into()); + } + let incarnation = session.binding.incarnation; + session + .actions + .send(Control { + label, + incarnation, + action, + text, + }) + .map_err(|e| e.to_string()) +} + +/// OS or menu close: an unguarded window closes; a guarded one asks its +/// companion to confirm. Returns true when the close was intercepted. +pub(crate) fn close_requested( + window: &tauri::Window, + api: &tauri::CloseRequestApi, +) -> bool { + if !is_popout(window.label()) { + return false; + } + let Some(host) = window.try_state::() else { + return false; + }; + if !host.intercept_close(window.label()) { + return false; + } + api.prevent_close(); + true +} +impl Popouts { + /// A guarded window's close is intercepted: the companion is asked. + fn intercept_close(&self, label: &str) -> bool { + let Ok(mut map) = self.0.lock() else { + return false; + }; + let Some(session) = map.get_mut(label) else { + return false; + }; + if !session.guarded { + return false; + } + session.view.close_requested += 1; + if let Some(updates) = &session.updates { + let _ = updates.send(session.view.clone()); + } + true + } +} + +/// Main is closing where that destroys the owner (Linux, Windows): unguarded +/// companions close with it; a companion holding unsaved work asks first and +/// keeps main open until it is resolved. Returns true when main must stay. +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub(crate) fn main_closing( + app: &tauri::AppHandle, + api: &tauri::CloseRequestApi, +) -> bool { + let Some(host) = app.try_state::() else { + return false; + }; + let Ok(mut map) = host.0.lock() else { + return false; + }; + let guarded: Vec = map + .iter() + .filter(|(_, s)| s.guarded) + .map(|(label, _)| label.clone()) + .collect(); + if guarded.is_empty() { + // Nothing to ask: every companion closes with its owner. + for label in map.keys() { + if let Some(window) = app.get_webview_window(label) { + let _ = window.destroy(); + } + } + return false; + } + // Ask first; nothing else closes until main's close is accepted, so + // Keep editing leaves every window as it was. + for label in &guarded { + if let Some(session) = map.get_mut(label) { + session.view.close_requested += 1; + if let Some(updates) = &session.updates { + let _ = updates.send(session.view.clone()); + } + } + } + api.prevent_close(); + if let Some(window) = guarded + .first() + .and_then(|label| app.get_webview_window(label)) + { + let _ = window.show(); + let _ = window.set_focus(); + } + true +} + +/// Main is gone: no companion can act without its owner. +pub(crate) fn main_destroyed(app: &tauri::AppHandle) { + let labels: Vec = app + .try_state::() + .and_then(|host| host.0.lock().ok().map(|map| map.keys().cloned().collect())) + .unwrap_or_default(); + for label in labels { + if let Some(window) = app.get_webview_window(&label) { + let _ = window.destroy(); + } + } +} + +/// Cmd+W with a pop-out focused closes that window through its guard. +pub(crate) fn close_focused(app: &tauri::AppHandle) -> bool { + let labels: Vec = app + .try_state::() + .and_then(|host| host.0.lock().ok().map(|map| map.keys().cloned().collect())) + .unwrap_or_default(); + for label in labels { + let Some(window) = app.get_webview_window(&label) else { + continue; + }; + if window.is_focused().unwrap_or(false) { + let _ = window.close(); + return true; + } + } + false +} + +/// Window destroyed by any path: forget its session. +pub(crate) fn destroyed(window: &tauri::Window) { + if !is_popout(window.label()) { + return; + } + if let Some(host) = window.try_state::() { + if let Ok(mut map) = host.0.lock() { + map.remove(window.label()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn binding() -> Binding { + Binding { + scope: "wss://relay.example".into(), + viewer: "a".repeat(64), + channel_id: "channel-1".into(), + thread_root_id: Some("b".repeat(64)), + incarnation: 1, + } + } + fn view() -> View { + View { + title: "Design".into(), + scope: String::new(), + dark: false, + appearance: None, + status: "ready".into(), + error: None, + writable: true, + can_load_more: false, + limited: false, + loading_older: false, + reading: None, + reading_revision: 0, + composer: None, + version: 1, + close_requested: 0, + detached: false, + root: None, + replies: vec![], + operations: vec![], + following: false, + is_dm: false, + kind: "channel".into(), + picture: None, + members: None, + icon: None, + thread: true, + can_go_back: false, + typing: None, + } + } + #[test] + fn binding_requires_hex_identities_and_bounded_ids() { + assert!(binding().valid()); + let mut b = binding(); + b.viewer = "nothex".into(); + assert!(!b.valid()); + let mut b = binding(); + b.thread_root_id = Some("short".into()); + assert!(!b.valid()); + let mut b = binding(); + b.channel_id = "x".repeat(257); + assert!(!b.valid()); + let mut b = binding(); + b.thread_root_id = None; + assert!(b.valid()); + } + #[test] + fn targets_ignore_incarnation_so_a_reloaded_owner_focuses_the_same_window() { + let mut later = binding(); + later.incarnation = 7; + assert_eq!(binding().target(), later.target()); + } + #[test] + fn presentation_is_bounded() { + assert!(validate(&view()).is_ok()); + let mut v = view(); + v.status = "weird".into(); + assert!(validate(&v).is_err()); + let mut v = view(); + v.title = "x".repeat(4097); + assert!(validate(&v).is_err()); + let mut v = view(); + v.composer = Some("not-a-uuid".into()); + assert!(validate(&v).is_err()); + let decoded: View = serde_json::from_value(serde_json::to_value(view()).unwrap()).unwrap(); + assert_eq!(decoded.title, "Design"); + } + #[test] + fn reactions_are_bounded_per_row() { + let mut r = row("r1", "https://media/a", "a.pdf"); + assert!(r.valid()); + let group = |content: &str, n: usize| { + serde_json::from_value::(serde_json::json!({ + "content": content, "emoji": null, + "events": (0..n).map(|i| serde_json::json!({ + "id": format!("{:0>64x}", i), "authorId": "a".repeat(64) + })).collect::>() + })) + .unwrap() + }; + r.reactions = vec![group("👍", 128), group(":party_blob:", 1)]; + assert!(r.valid()); + r.reactions = vec![group("👍", 129)]; + assert!(!r.valid()); + r.reactions = vec![group(&"x".repeat(67), 1)]; + assert!(!r.valid()); + r.reactions = (0..65).map(|_| group("👍", 1)).collect(); + assert!(!r.valid()); + r.reactions = vec![group("👍", 0)]; + assert!(!r.valid()); + } + #[test] + fn native_background_matches_the_applied_color_mode_tokens() { + let tokens = include_str!("../../src/shared/design-system/styles/tokens.css"); + let hex = |c: tauri::window::Color| format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2); + let appearance = |mode: &str| -> crate::huddle_window::Appearance { + serde_json::from_value(serde_json::json!({ "colorMode": mode })).unwrap() + }; + // Resolve `--bg-app` as the stylesheet does for each mode. Mode-paired + // tokens are declared twice in `tokens.css`: the light (`:root`) value + // first, the dark (`[data-color-mode="dark"]`) value second; the role + // block may alias a palette step that differs per mode. + let declared = |mode: &str, name: &str| -> String { + let needle = format!("{name}: "); + let mut found = tokens.match_indices(&needle).map(|(at, _)| { + let value = &tokens[at + needle.len()..]; + value[..value.find(';').unwrap()].trim().to_string() + }); + let light = found + .next() + .unwrap_or_else(|| panic!("{name} not declared")); + if mode == "dark" { + found + .next() + .unwrap_or_else(|| panic!("{name} has no dark value")) + } else { + light + } + }; + let resolve = |mode: &str| -> String { + let mut value = declared(mode, "--bg-app"); + while let Some(alias) = value.strip_prefix("var(").and_then(|v| v.strip_suffix(')')) { + value = declared(mode, alias); + } + value + }; + assert_eq!(hex(appearance("dark").background()), resolve("dark")); + assert_eq!(hex(appearance("light").background()), resolve("light")); + assert_ne!(resolve("dark"), resolve("light")); + } + #[test] + fn link_labels_are_bounded_per_row() { + let mut r = row("r1", "https://media/a", "a.pdf"); + let link = |n: usize| format!("buzz://message?channel=c&id={:0>64x}", n); + r.link_labels = (0..64) + .map(|i| (link(i), "Ada in #design".into())) + .collect(); + assert!(r.valid()); + r.link_labels = (0..65) + .map(|i| (link(i), "Ada in #design".into())) + .collect(); + assert!(!r.valid()); + r.link_labels = [(link(0), "x".repeat(1001))].into(); + assert!(!r.valid()); + r.link_labels = [("u".repeat(8193), "Ada in #design".into())].into(); + assert!(!r.valid()); + let decoded: DiscussionRow = serde_json::from_value(serde_json::json!({ + "id": "r", "authorId": "a", "channelId": "c", "author": "Ada", "picture": null, + "text": "see", "time": 1, "delivery": "seen", "attachments": [], + "linkLabels": { "buzz://message?channel=c&id=1": "Ada in #design" } + })) + .unwrap(); + assert_eq!( + decoded.link_labels["buzz://message?channel=c&id=1"], + "Ada in #design" + ); + } + #[test] + fn labels_are_recognized_by_prefix() { + assert!(is_popout("popout-1234")); + assert!(!is_popout("main")); + assert!(!is_popout("huddle")); + } + fn row(id: &str, source: &str, name: &str) -> DiscussionRow { + serde_json::from_value(serde_json::json!({ + "id": id, "authorId": "a", "channelId": "channel", "author": "Ada", + "picture": null, "text": "see file", "time": 1, "delivery": "seen", + "attachments": [{ + "url": "https://relay.example/media/1", "kind": "file", "mime": null, + "size": null, "name": name, "duration": null, "voiceNote": null, + "waveform": null, "dimensions": null, "blurhash": null, + "previewUrl": null, "source": source, "previewSource": null + }] + })) + .unwrap() + } + fn session(binding: Binding, view: View, guarded: bool) -> Session { + Session { + binding, + view, + guarded, + actions: Channel::new(|_| Ok(())), + updates: None, + } + } + #[test] + fn window_title_names_the_thread_and_its_conversation() { + let mut view = view(); + view.title = "design".into(); + view.thread = true; + assert_eq!(view.window_title(), "Thread in #design — Buzz"); + view.is_dm = true; + view.kind = "dm".into(); + view.title = "Ada".into(); + assert_eq!(view.window_title(), "Thread in Ada (DM) — Buzz"); + view.thread = false; + assert_eq!(view.window_title(), "Ada (DM) — Buzz"); + view.kind = "solo".into(); + view.title = "Planning".into(); + assert_eq!(view.window_title(), "Planning (Solo chat) — Buzz"); + view.is_dm = false; + view.kind = "private".into(); + view.title = "leads".into(); + assert_eq!(view.window_title(), "leads — Buzz"); + view.kind = "channel".into(); + view.title = "design".into(); + assert_eq!(view.window_title(), "#design — Buzz"); + // The kind is a closed set; a picture is bounded. + view.kind = "other".into(); + assert!(validate(&view).is_err()); + view.kind = "dm".into(); + view.picture = Some("x".repeat(8193)); + assert!(validate(&view).is_err()); + view.picture = Some("http://buzz-media.localhost/avatar".into()); + assert!(validate(&view).is_ok()); + // Members: a bounded preview of at most three. + let person = |n: usize| MemberPreview { + id: format!("{n:0>64}"), + name: "Ada".into(), + picture: None, + }; + view.members = Some(Members { + count: 12, + solo: false, + preview: (0..3).map(person).collect(), + }); + assert!(validate(&view).is_ok()); + view.members = Some(Members { + count: 12, + solo: false, + preview: (0..4).map(person).collect(), + }); + assert!(validate(&view).is_err()); + view.members = Some(Members { + count: 1_000_001, + solo: false, + preview: vec![], + }); + assert!(validate(&view).is_err()); + let action: Action = serde_json::from_str("\"openMembers\"").unwrap(); + assert!(action == Action::OpenMembers); + // Working agents: bounded, hex-identified, closed state set. + let working = |n: usize, state: &str| -> Typing { + serde_json::from_value(serde_json::json!({ + "humans": [], "more": 0, "agents": ["Echo"], + "working": (0..n).map(|i| serde_json::json!({ + "id": format!("{i:0>64}"), "name": "Echo", "picture": null, + "state": state, "step": "Reading files", "status": "Reading files", + "stops": [{ "key": "[\"a\",\"c\",\"m\"]", "label": "Stop Echo" }] + })).collect::>() + })) + .unwrap() + }; + view.members = None; + // A plugin chat icon is a bounded token with a label. + view.icon = Some(ChatIcon { + icon: "git-pull-request".into(), + tone: Some("open".into()), + label: "Pull request, open".into(), + }); + assert!(validate(&view).is_ok()); + view.icon = Some(ChatIcon { + icon: "".into(), + tone: None, + label: "x".into(), + }); + assert!(validate(&view).is_err()); + view.icon = None; + view.typing = Some(working(8, "working")); + assert!(validate(&view).is_ok()); + view.typing = Some(working(9, "working")); + assert!(validate(&view).is_err()); + view.typing = Some(working(1, "done")); + assert!(validate(&view).is_err()); + } + #[test] + fn focus_main_is_a_companion_action_with_no_payload() { + let action: Action = serde_json::from_str("\"focusMain\"").unwrap(); + assert!(action == Action::FocusMain); + assert_eq!(serde_json::to_string(&action).unwrap(), "\"focusMain\""); + } + #[test] + fn commands_are_gated_by_caller_label() { + let app = tauri::test::mock_builder() + .manage(Popouts::default()) + .build(crate::app_context()) + .unwrap(); + let main = tauri::WebviewWindowBuilder::new(&app, "main", tauri::WebviewUrl::default()) + .build() + .unwrap(); + let popout = + tauri::WebviewWindowBuilder::new(&app, "popout-a", tauri::WebviewUrl::default()) + .build() + .unwrap(); + let huddle = tauri::WebviewWindowBuilder::new(&app, "huddle", tauri::WebviewUrl::default()) + .build() + .unwrap(); + assert!(require_main(&main).is_ok()); + assert!(require_main(&popout).is_err()); + assert_eq!(require_popout(&popout).unwrap(), "popout-a"); + assert!(require_popout(&main).is_err()); + assert!(require_popout(&huddle).is_err()); + } + #[test] + fn downloads_are_isolated_to_the_calling_window_projection() { + let host = Popouts::default(); + let mut a = view(); + a.root = Some(row("r1", "https://media/a", "a.pdf")); + let mut b = view(); + b.replies = vec![row("r2", "https://media/b", "b.pdf")]; + { + let mut map = host.0.lock().unwrap(); + map.insert("popout-a".into(), session(binding(), a, false)); + map.insert("popout-b".into(), session(binding(), b, false)); + } + assert!(host.permits_download("popout-a", "https://media/a", "a.pdf")); + assert!(!host.permits_download("popout-a", "https://media/b", "b.pdf")); + assert!(host.permits_download("popout-b", "https://media/b", "b.pdf")); + assert!(!host.permits_download("popout-b", "https://media/a", "a.pdf")); + assert!(!host.permits_download("popout-a", "https://media/a", "other.pdf")); + assert!(!host.permits_download("popout-zzz", "https://media/a", "a.pdf")); + } + #[test] + fn guarded_close_is_intercepted_and_asks_the_companion() { + let host = Popouts::default(); + host.0 + .lock() + .unwrap() + .insert("popout-a".into(), session(binding(), view(), true)); + assert!(host.intercept_close("popout-a")); + assert_eq!(host.0.lock().unwrap()["popout-a"].view.close_requested, 1); + assert!(host.intercept_close("popout-a")); + assert_eq!(host.0.lock().unwrap()["popout-a"].view.close_requested, 2); + host.0.lock().unwrap().get_mut("popout-a").unwrap().guarded = false; + assert!( + !host.intercept_close("popout-a"), + "an unguarded close proceeds" + ); + assert!(!host.intercept_close("popout-unknown")); + } + #[test] + fn companion_has_no_identity_relay_or_signing_authority() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/popout.json")).unwrap(); + assert_eq!(capability["webviews"], serde_json::json!(["popout-*"])); + assert_eq!( + capability["permissions"], + serde_json::json!([ + "allow-popout-window-watch", + "allow-popout-window-action", + "allow-media-download", + "allow-clipboard-write-text", + { "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] } + ]) + ); + } + #[test] + fn destruction_sends_closed_off_the_event_thread_once() { + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + let (sender, receiver) = mpsc::channel(); + let actions = Channel::new(move |_| { + sender.send(thread::current().id()).unwrap(); + Ok(()) + }); + let notice = closed_notice(actions, "popout-x".into(), 1); + notice(&tauri::WindowEvent::Destroyed); + notice(&tauri::WindowEvent::Destroyed); + let thread = receiver.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_ne!(thread, thread::current().id()); + drop(notice); + assert!(receiver.recv().is_err()); + } +} diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index 1ac49d1b0c..ebeaaf1c0a 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -2102,6 +2102,9 @@ pub(crate) async fn media_download( "huddle" => app .try_state::() .is_some_and(|host| host.permits_download(&source, &name)), + label if crate::popout_window::is_popout(label) => app + .try_state::() + .is_some_and(|host| host.permits_download(label, &source, &name)), _ => false, }; if !allowed { diff --git a/src/app/App.exit-holds.test.tsx b/src/app/App.exit-holds.test.tsx new file mode 100644 index 0000000000..6ea9210fa6 --- /dev/null +++ b/src/app/App.exit-holds.test.tsx @@ -0,0 +1,337 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { finalizeEvent, getPublicKey } from "nostr-tools"; +import { StrictMode } from "react"; +import { App } from "./App"; +import { createServices, type AppServices } from "./services"; +import { matchesEvent } from "../features/relay/projection"; +import type { ReadFilter, RelayEvent } from "../features/relay/events"; +import { composerDOMFixture } from "../features/messages/composer-testing"; + +composerDOMFixture(); + +// jsdom has no IndexedDB; nothing is sent in this flow. +vi.mock("../features/relay/outbox-storage", () => ({ + browserOutboxStorage: () => ({ load: () => [], save: () => {} }), +})); +vi.mock("../bundled", async () => ({ + bundledPlugins: [ + { + manifest: { id: "buzz.channels", name: "Channels", apiVersion: 1 }, + module: await import("../bundled/channels"), + }, + { + manifest: { id: "buzz.profiles", name: "Profiles", apiVersion: 1 }, + module: await import("../bundled/profiles"), + }, + ], +})); +const key = new Uint8Array(32).fill(6), + viewer = getPublicKey(key), + origin = "https://community.example", + other = "https://other.example"; +const profile = finalizeEvent( + { + kind: 0, + created_at: 1, + content: JSON.stringify({ name: "Community name" }), + tags: [], + }, + key, +); +let services: AppServices | undefined; +afterEach(async () => { + cleanup(); + await services?.dispose(); + services = undefined; + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +async function setup() { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation( + function (this: HTMLElement) { + return this.parentElement; + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubEnv("VITE_BUZZ_LIVE", "1"); + localStorage.setItem( + `buzz-client.v1:${viewer}`, + JSON.stringify({ + profile: { name: "Local name", picture: "" }, + memberships: [ + { id: origin, name: "Fixture community" }, + { id: other, name: "Other community" }, + ], + selected: origin, + }), + ); + const events: RelayEvent[] = [profile]; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, options?: RequestInit) => { + if (url.endsWith("/identity")) return Response.json({ viewer }); + if (url.endsWith("/session")) { + const destination = decodeURIComponent(url.split("/")[3] ?? ""); + return Response.json({ + viewer, + relayAuthor: viewer, + relayUrl: destination.includes("other") ? other : origin, + writeKinds: [9], + }); + } + if (url.endsWith("/query")) { + const filters = JSON.parse(String(options?.body)) as ReadFilter[]; + return Response.json( + events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ); + } + return Response.json({}); + }), + ); + const user = userEvent.setup(); + services = createServices(); + const current = services; + render( + + + , + ); + await waitFor(() => expect(current.relay.snapshot().status).toBe("ready")); + let holds: string[] = [ + "The pop-out window for “Design” has an unsent reply.", + ]; + const release = current.communities.holdExit(() => holds); + const prompt = () => + screen.findByRole("alertdialog", { name: "Switch community?" }); + const noPrompt = () => + waitFor(() => + expect( + screen.queryByRole("alertdialog", { name: "Switch community?" }), + ).toBeNull(), + ); + return { + user, + services: current, + prompt, + noPrompt, + clearHolds: () => { + holds = []; + }, + release, + selected: () => current.communities.snapshot().selected, + }; +} + +it("asks before a direct community switch discards held work, and keeps or switches as chosen", async () => { + const h = await setup(); + const after = vi.fn(); + expect(h.services.communities.select(other, { after })).toBe(false); + const dialog = await h.prompt(); + expect(dialog).toHaveTextContent("has an unsent reply"); + await h.user.click( + within(dialog).getByRole("button", { name: "Keep working" }), + ); + await h.noPrompt(); + expect(h.selected()).toBe(origin); + expect(after).not.toHaveBeenCalled(); + h.services.communities.select(other, { after }); + await h.user.click( + within(await h.prompt()).getByRole("button", { name: "Switch anyway" }), + ); + await h.noPrompt(); + await waitFor(() => expect(h.selected()).toBe(other)); + expect(after).toHaveBeenCalledTimes(1); + // With nothing held, switching back asks nothing. + h.clearHolds(); + expect(h.services.communities.select(origin)).toBe(true); + expect(h.selected()).toBe(origin); +}); + +it("asks before a cross-community navigation commits, preserving the route on cancel", async () => { + const h = await setup(); + const before = h.services.navigation.snapshot().entry; + const target = { + version: 1 as const, + kind: "page" as const, + pluginId: "buzz.channels", + pageId: "channels", + scope: { viewer, communityOrigin: other }, + }; + let opening = h.services.navigation.open(target); + const dialog = await h.prompt(); + await h.user.click( + within(dialog).getByRole("button", { name: "Keep working" }), + ); + await h.noPrompt(); + expect(h.selected()).toBe(origin); + expect(h.services.navigation.snapshot().entry.id).toBe(before.id); + expect(h.services.communities.pendingSelection()).toBeUndefined(); + await expect(opening).resolves.toMatchObject({ status: "cancelled" }); + opening = h.services.navigation.open(target); + await h.user.click( + within(await h.prompt()).getByRole("button", { name: "Switch anyway" }), + ); + await h.noPrompt(); + await waitFor(() => expect(h.selected()).toBe(other)); + await opening; + // Navigation within the same community asks nothing. + await act(async () => { + await h.services.navigation.open({ + version: 1, + kind: "settings", + section: "profile", + }); + }); + expect( + screen.queryByRole("alertdialog", { name: "Switch community?" }), + ).toBeNull(); +}); + +it("names unsaved Settings edits in the same prompt and asks only once", async () => { + const h = await setup(); + await act(async () => { + void h.services.navigation.open({ + version: 1, + kind: "settings", + section: "profile", + }); + }); + const settings = await screen.findByRole("dialog", { name: "Settings" }); + const name = await within(settings).findByRole("textbox", { + name: "Display name", + }); + await waitFor(() => expect(name).toHaveValue("Community name")); + await h.user.clear(name); + await h.user.type(name, "Unsaved name"); + h.services.communities.select(other, { + after: () => + void h.services.navigation.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + scope: { viewer, communityOrigin: other }, + }), + }); + const dialog = await h.prompt(); + expect(dialog).toHaveTextContent("has an unsent reply"); + expect(dialog).toHaveTextContent("Your profile has unsaved changes."); + await h.user.click( + within(dialog).getByRole("button", { name: "Switch anyway" }), + ); + await h.noPrompt(); + await waitFor(() => expect(h.selected()).toBe(other)); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull(), + ); + expect( + screen.queryByRole("alertdialog", { name: "Discard changes?" }), + ).toBeNull(); +}); + +it("warns about both the editor and held work before a cross-community navigation, in one prompt", async () => { + const h = await setup(); + await act(async () => { + void h.services.navigation.open({ + version: 1, + kind: "settings", + section: "profile", + }); + }); + const settings = await screen.findByRole("dialog", { name: "Settings" }); + const name = await within(settings).findByRole("textbox", { + name: "Display name", + }); + await waitFor(() => expect(name).toHaveValue("Community name")); + await h.user.clear(name); + await h.user.type(name, "Unsaved name"); + const target = { + version: 1 as const, + kind: "page" as const, + pluginId: "buzz.channels", + pageId: "channels", + scope: { viewer, communityOrigin: other }, + }; + const before = h.services.navigation.snapshot().entry.id; + let opening = h.services.navigation.open(target); + const dialog = await h.prompt(); + expect(dialog).toHaveTextContent("has an unsent reply"); + expect(dialog).toHaveTextContent("Your profile has unsaved changes."); + expect( + screen.queryByRole("alertdialog", { name: "Discard changes?" }), + ).toBeNull(); + // Keep working: route, community and the editor's text all stay. + await h.user.click( + within(dialog).getByRole("button", { name: "Keep working" }), + ); + await h.noPrompt(); + await expect(opening).resolves.toMatchObject({ status: "cancelled" }); + expect(h.selected()).toBe(origin); + expect(h.services.navigation.snapshot().entry.id).toBe(before); + expect(name).toHaveValue("Unsaved name"); + opening = h.services.navigation.open(target); + await h.user.click( + within(await h.prompt()).getByRole("button", { name: "Switch anyway" }), + ); + await h.noPrompt(); + await waitFor(() => expect(h.selected()).toBe(other)); + await opening; + expect( + screen.queryByRole("alertdialog", { name: "Discard changes?" }), + ).toBeNull(); +}); + +it("keeps a newly joined community admitted but unselected while work is held", async () => { + const h = await setup(); + const joined = "https://new.example"; + h.services.communities.joined( + { id: joined, name: "New" }, + { name: "L", picture: "" }, + ); + expect(h.selected()).toBe(origin); + expect( + h.services.communities.snapshot().memberships.map((m) => m.id), + ).toContain(joined); + const dialog = await h.prompt(); + await h.user.click( + within(dialog).getByRole("button", { name: "Keep working" }), + ); + await h.noPrompt(); + expect(h.selected()).toBe(origin); + // Joining again, or selecting it, asks again and then switches. + h.services.communities.select(joined); + await h.user.click( + within(await h.prompt()).getByRole("button", { name: "Switch anyway" }), + ); + await waitFor(() => expect(h.selected()).toBe(joined)); +}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 33905868d3..8aa7fbe4bd 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -157,8 +157,25 @@ function ConnectedAppContent({ services }: { services: AppServices }) { ); const discardSettings = useRef(false); const settingsEditorScope = useRef(undefined); + // Work a community exit would discard (pop-out windows with unsent replies, + // say): a direct selection waits as a pending selection; a navigation that + // would change community asks here, before its route commits. + const exitHolds = useSyncExternalStore( + services.communities.subscribe, + services.communities.exitHolds, + ); + const pendingSelection = useSyncExternalStore( + services.communities.subscribe, + services.communities.pendingSelection, + ); + const exitTraversal = useRef< + { holds: readonly string[]; decide(proceed: boolean): void } | undefined + >(undefined); + const [confirmExit, setConfirmExit] = useState(false); + const holdingExit = exitHolds.length > 0; useLayoutEffect(() => { - if (!settingsUnsaved) return; + if (!settingsUnsaved && !holdingExit) return; + // One guard in the host's single slot decides both concerns. return services.navigationHost.guardDeparture((next, decide) => { if (discardSettings.current) { discardSettings.current = false; @@ -167,17 +184,49 @@ function ConnectedAppContent({ services }: { services: AppServices }) { const current = services.navigation.snapshot().entry.target; // Settings sections share the mounted Profile editor. A different scope // or leaving the dialog would unmount it. - if ( - next.kind === "settings" && - current.kind === "settings" && - JSON.stringify(next.scope) === JSON.stringify(current.scope) - ) - return false; - settingsTraversal.current = decide; - setConfirmSettingsDiscard(true); - return true; + const leavingEditor = + settingsUnsaved && + !( + next.kind === "settings" && + current.kind === "settings" && + JSON.stringify(next.scope) === JSON.stringify(current.scope) + ); + const holds = services.communities.exitHolds(); + const selected = services.communities.snapshot().selected; + const origin = selected ? communityDestination(selected).url : null; + const nextOrigin = + "scope" in next ? (next.scope?.communityOrigin ?? null) : origin; + const leavingCommunity = holds.length > 0 && nextOrigin !== origin; + // One decision covers both: the community prompt also names the editor. + if (leavingCommunity) { + exitTraversal.current = { holds, decide }; + setConfirmExit(true); + return true; + } + if (leavingEditor) { + settingsTraversal.current = decide; + setConfirmSettingsDiscard(true); + return true; + } + return false; }); - }, [services, settingsUnsaved]); + }, [services, settingsUnsaved, holdingExit]); + const keepCommunity = () => { + exitTraversal.current?.decide(false); + exitTraversal.current = undefined; + setConfirmExit(false); + services.communities.cancelSelection(); + }; + const leaveCommunity = () => { + const traversal = exitTraversal.current; + exitTraversal.current = undefined; + setConfirmExit(false); + // Confirming the switch also settles unsaved Settings edits: the dialog + // named them, and the route that follows must not ask again. + if (settingsUnsaved) discardSettings.current = true; + if (traversal) traversal.decide(true); + else services.communities.confirmSelection(); + }; const keepSettings = () => { settingsTraversal.current?.(false); settingsTraversal.current = undefined; @@ -367,8 +416,12 @@ function ConnectedAppContent({ services }: { services: AppServices }) { const recovering = services.navigation.snapshot().ingress && services.navigation.snapshot().retryable; - services.communities.select(id); - if (!recovering) select("buzz.channels/channels"); + // Navigate only once selected: a held exit asks first and continues here. + services.communities.select(id, { + after: () => { + if (!recovering) select("buzz.channels/channels"); + }, + }); }; if (firstRun) return ( @@ -657,6 +710,7 @@ function ConnectedAppContent({ services }: { services: AppServices }) { agents={services.agents} chatIcons={services.conversation.chatIcons} chatMenus={services.conversation.chatMenus} + tabActions={services.conversation.tabActions} sidebarSections={services.sidebarSections} sidebarScope={services.sidebarScope} sidebarRows={services.sidebarRows} @@ -708,6 +762,42 @@ function ConnectedAppContent({ services }: { services: AppServices }) { relay={services.relay} /> + {(confirmExit || pendingSelection) && ( + + Switching closes work in this community that isn’t saved + anywhere else: +
    + {[ + ...(exitTraversal.current?.holds ?? + pendingSelection?.holds ?? + []), + ...(settingsUnsaved + ? ["Your profile has unsaved changes."] + : []), + ].map((hold) => ( +
  • {hold}
  • + ))} +
+ + } + onClose={keepCommunity} + actions={ + <> + + + + } + /> + )} diff --git a/src/app/IdentitySettings.test.tsx b/src/app/IdentitySettings.test.tsx index b8a521b234..baf3be0d60 100644 --- a/src/app/IdentitySettings.test.tsx +++ b/src/app/IdentitySettings.test.tsx @@ -19,7 +19,10 @@ import { import { BACKUP_FAILURES } from "../features/identity/backup-failure"; import { backupOfferedFor } from "../features/identity/builderlab-account"; import type { Identity } from "../features/identity/service"; -import type { FederatedIdentity } from "../features/federated-identity/service"; +import type { + FederatedIdentity, + SessionSnapshot, +} from "../features/federated-identity/service"; import { IdentitySettings } from "./IdentitySettings"; import { federatedFixture } from "../features/federated-identity/testing"; @@ -1181,3 +1184,116 @@ it.each([ } }, ); + +it("names held work in the organization sign-out confirmation, and signs out of that server once on confirmation", async () => { + const { block, pub, root, servers } = twoServers(); + block.publish({ status: "signed-in", email: "will@acme.example" }); + pub.publish({ status: "signed-in", email: "will@example.com" }); + render( + , + { wrapper: ToastProvider }, + ); + const signOut = async () => + userEvent.click( + await screen.findByRole("button", { + name: "Sign out of app.builderlab.xyz", + }), + ); + await signOut(); + expect(pub.service.signOut).not.toHaveBeenCalled(); + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toHaveTextContent("Signing out also discards:"); + expect(dialog).toHaveTextContent("has an unsent reply"); + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(pub.service.signOut).not.toHaveBeenCalled(); + await signOut(); + await userEvent.click( + within(screen.getByRole("alertdialog")).getByRole("button", { + name: "Sign out anyway", + }), + ); + expect(pub.service.signOut).toHaveBeenCalledTimes(1); + expect(block.service.signOut).not.toHaveBeenCalled(); +}); + +it("drops a pending organization sign-out decision when that server's session is replaced", async () => { + const { block, pub, root, servers } = twoServers(); + block.publish({ status: "signed-in", email: "will@acme.example" }); + pub.publish({ + status: "signed-in", + revision: "account:1", + email: "will@example.com", + }); + render( + , + { wrapper: ToastProvider }, + ); + const signOut = async () => + userEvent.click( + await screen.findByRole("button", { + name: "Sign out of app.builderlab.xyz", + }), + ); + await signOut(); + expect(screen.getByRole("alertdialog")).toBeVisible(); + act(() => + pub.publish({ status: "signed-out", revision: "account:2", email: null }), + ); + act(() => + pub.publish({ + status: "signed-in", + revision: "account:3", + email: "other@example.com", + }), + ); + // The question was about account 1; the replacement session gets a fresh one. + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(pub.service.signOut).not.toHaveBeenCalled(); + await signOut(); + await userEvent.click( + within(screen.getByRole("alertdialog")).getByRole("button", { + name: "Sign out anyway", + }), + ); + expect(pub.service.signOut).toHaveBeenCalledTimes(1); + expect(block.service.signOut).not.toHaveBeenCalled(); +}); + +it("names held work in the fallback account's sign-out too, while server discovery is pending or failed", async () => { + const h = federatedFixture(); + h.publish({ status: "signed-in", email: "a@example.com" }); + // Discovery never answers: only the root account's own row is shown. + const servers = () => new Promise(() => {}); + render( + , + { wrapper: ToastProvider }, + ); + await userEvent.click( + await screen.findByRole("button", { name: "Sign out of organization" }), + ); + const dialog = await screen.findByRole("alertdialog"); + expect(dialog).toHaveTextContent("has an unsent reply"); + expect(h.service.signOut).not.toHaveBeenCalled(); + await userEvent.click( + within(dialog).getByRole("button", { name: "Sign out anyway" }), + ); + expect(h.service.signOut).toHaveBeenCalledTimes(1); +}); diff --git a/src/app/IdentitySettings.tsx b/src/app/IdentitySettings.tsx index db95f18ef2..8117680a85 100644 --- a/src/app/IdentitySettings.tsx +++ b/src/app/IdentitySettings.tsx @@ -137,6 +137,7 @@ export function IdentitySettings({ profileSession, community, memberships = [], + exitHolds, }: { viewer?: string | undefined; identity?: Identity | undefined; @@ -154,6 +155,8 @@ export function IdentitySettings({ profileSession?: RelaySession | undefined; community?: Membership | undefined; memberships?: readonly Membership[]; + /** Work that signing out would discard, named in its confirmation. */ + exitHolds?: readonly string[] | undefined; }) { const [signingOut, setSigningOut] = useState(false); const [offered, setOffered] = useState(false); @@ -407,6 +410,7 @@ export function IdentitySettings({ servers={servers} accountRows={accountRows} memberships={memberships} + holds={exitHolds ?? []} /> )} @@ -437,6 +441,7 @@ export function IdentitySettings({ setSigningOut(false)} + holds={exitHolds} /> )} @@ -493,11 +498,14 @@ function OrganizationSignIns({ servers, accountRows, memberships, + holds, }: { identity: FederatedIdentity; servers: () => Promise; accountRows?: ReactNode; memberships: readonly Membership[]; + /** Work that signing out would discard; while any exists, signing out asks. */ + holds: readonly string[]; }) { const [ids, setIds] = useState([]); useEffect(() => { @@ -580,6 +588,7 @@ function OrganizationSignIns({ identity={identity} accountRows={accountRows} memberships={memberships} + holds={holds} /> ) : ( accountDetails && ( @@ -611,6 +620,7 @@ function OrganizationSignIns({ signOut(session)} /> @@ -804,12 +814,16 @@ function OrganizationSignIn({ listed = false, memberships = [], onSignOut, + holds = [], }: { identity: FederatedIdentity; accountRows?: ReactNode; listed?: boolean; memberships?: readonly Membership[]; onSignOut?: () => void; + /** Work that signing out would discard (a pop-out's unsent reply), named + * in the confirmation so the choice is informed. */ + holds?: readonly string[]; }) { const state = useSyncExternalStore( identity.subscribe, @@ -957,7 +971,22 @@ function OrganizationSignIn({ {signedIn && confirmRevision === state.revision && ( + Your Buzz identity stays on this device. You and your agents + can’t reconnect to communities that require your {organization}{" "} + login until you sign back in. Signing out also discards: +
    + {holds.map((hold) => ( +
  • {hold}
  • + ))} +
+ + ) : ( + `Your Buzz identity stays on this device. You and your agents can’t reconnect to communities that require your ${organization} login until you sign back in.` + ) + } finalFocus={trigger} onClose={() => setConfirmRevision(null)} actions={ @@ -978,7 +1007,7 @@ function OrganizationSignIn({ }); }} > - Sign out + {holds.length ? "Sign out anyway" : "Sign out"} } diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index ab79569bee..1dbd8f6ab6 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -664,6 +664,9 @@ function PluginDetails({ ); } +const none: readonly string[] = []; +const noHolds = () => none; + export function Settings({ agents, decisions, @@ -761,6 +764,11 @@ export function Settings({ communities.subscribe, communities.snapshot, ); + // Fixtures may supply a bare community inventory without exit holds. + const exitHolds = useSyncExternalStore( + communities.subscribe, + communities.exitHolds ?? noHolds, + ); const selectedCommunity = client.memberships.find( (membership) => membership.id === client.selected, ); @@ -1523,6 +1531,7 @@ export function Settings({ ) : undefined } + exitHolds={exitHolds} /> )} {selected === "security" && ( diff --git a/src/app/navigation.ts b/src/app/navigation.ts index c4b892479a..82e10ac129 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -365,12 +365,14 @@ export function useAppNavigation( }); return; } + // The route already passed the shell's departure guard, which asks about + // held work before a community change commits; select without asking again. if ( scope === null && client.status !== "loading" && client.selected !== null ) - services.communities.select(null); + services.communities.select(null, { force: true }); if ( scope && client.status !== "loading" && @@ -378,7 +380,7 @@ export function useAppNavigation( membership && client.selected !== membership.id ) { - services.communities.select(membership.id); + services.communities.select(membership.id, { force: true }); } }, [services, state.attempt, failure, scope, client, membership]); // A visit that failed while its connection was down, or whose connection has diff --git a/src/app/services.ts b/src/app/services.ts index 3d618d92a7..e6a87a6d72 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -128,6 +128,8 @@ export function createServices() { ctx.provide("communityReader", { snapshot: communities.snapshot, subscribe: communities.subscribe, + holdExit: communities.holdExit, + exitHolds: communities.exitHolds, }); ctx.provide("knownCommunities", { snapshot: communities.snapshot, @@ -192,14 +194,17 @@ export function createServices() { return inviteIntent.open(invite, viewer); inviteIntent.clear(); // Select now, not in the navigation effect, so a link read in the - // meantime is not discarded as a community switch. - communities.select(id); - void navigation.open({ - version: 1, - kind: "page", - pluginId: "buzz.channels", - pageId: "channels", - scope: { viewer, communityOrigin: invite.community }, + // meantime is not discarded as a community switch. A held exit asks + // first and opens the link once confirmed. + communities.select(id, { + after: () => + void navigation.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + scope: { viewer, communityOrigin: invite.community }, + }), }); }, }; diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx index 6400176120..6417207858 100644 --- a/src/app/shell/PrimarySidebar.tsx +++ b/src/app/shell/PrimarySidebar.tsx @@ -42,6 +42,7 @@ import type { ChannelList } from "../../features/relay/contracts"; import type { ContributionReader } from "../../features/conversation/contracts"; import type { ChatIcon, ChatMenu } from "../../features/conversation/service"; import type { SidebarRows } from "../../features/channel-navigation/sidebar-rows-service"; +import type { TabAction } from "../../features/conversation/contracts"; import type { SidebarSections } from "../../features/channel-navigation/sidebar-sections-service"; import { CommunityRefusalNotice, @@ -68,6 +69,8 @@ type Props = { agents?: Agents | undefined; chatIcons?: ContributionReader | undefined; chatMenus?: ContributionReader | undefined; + /** Conversation actions a sidebar row also offers (menu and ⌘-click). */ + tabActions?: ContributionReader | undefined; sidebarSections?: SidebarSections | undefined; /** A plugin restriction shows only Solo; the saved filter returns after. */ sidebarScope?: SidebarScope | undefined; @@ -264,6 +267,7 @@ function ScopedSidebar({ viewer={connection.viewer} sidebarSections={props.sidebarSections} sidebarRows={props.sidebarRows} + tabActions={props.tabActions} cached={!!connection.cached} layoutReady={filter === "team" || soloReady} connectionError={connection.error} @@ -313,6 +317,7 @@ function ScopedSidebar({ chatIcons={props.chatIcons} chatMenus={props.chatMenus} sidebarRows={props.sidebarRows} + tabActions={props.tabActions} renderContents={(ordinary, header, pendingClick) => ( ; } = {}, ) { const owner = createRelaySession(host.transport ?? null); @@ -246,6 +250,7 @@ export function fixture( chatIcons={chatIcons} chatMenus={chatMenus} sidebarRows={host.sidebarRows} + tabActions={host.tabActions} sidebarScope={host.sidebarScope} {...(host.federatedIdentity ? { federatedIdentity: host.federatedIdentity } @@ -265,6 +270,7 @@ export function fixture( chatIcons={chatIcons} chatMenus={chatMenus} sidebarRows={host.sidebarRows} + tabActions={host.tabActions} /> )} diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index f05122b924..2004a04943 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -16,6 +16,7 @@ import { ChatNotStarted } from "../../features/relay/channel-writes"; import { keypair, signed } from "../../features/relay/testing"; import type { RelayData } from "../../features/relay/service"; import type { ChannelSummary } from "../../features/relay/contracts"; +import { soloAccessRequests } from "../../features/solo/access-requests"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import type { PageNavigation } from "../../features/navigation/service"; import type { Navigation } from "../../features/navigation/controller"; @@ -50,6 +51,8 @@ composerDOMFixture(); const owners: ReturnType[] = []; afterEach(() => { cleanup(); + soloAccessRequests.clear(); + tabActionEntries = []; localStorage.clear(); for (const owner of owners.splice(0)) owner.dispose(); vi.unstubAllGlobals(); @@ -58,7 +61,16 @@ const id = "11111111-1111-4111-8111-111111111111"; const agent = "a".repeat(64); const empty: readonly never[] = []; const contribution = { snapshot: () => empty, subscribe: () => () => {} }; -const extensions = { tools: contribution, inline: contribution }; +// Contributed conversation actions a test may register; one stable array per +// test, as an external-store snapshot must be. +let tabActionEntries: readonly import("../../plugins/contributions").Contribution< + import("../../features/conversation/contracts").TabAction +>[] = []; +const extensions = { + tools: contribution, + inline: contribution, + tabActions: { snapshot: () => tabActionEntries, subscribe: () => () => {} }, +}; function fixture({ placed = true, type = "solo", @@ -1316,6 +1328,39 @@ it("offers the header's chat actions from the sidebar row, plus inline Rename", expect(await items("Actions for Planning")).toEqual(["Rename", ...header]); }); +it("offers a contributed conversation action in the header's Chat actions, right after Copy link", async () => { + const run = vi.fn(); + tabActionEntries = [ + { + id: "open-window", + key: "popout/open-window", + pluginId: "popout", + revision: "1", + title: "Open in new window", + icon: () => , + matches: () => true, + run: (target) => run(target.channelId), + }, + ]; + const f = fixture(); + f.mount(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Chat actions" })); + const menu = await screen.findByRole("menu", { name: "Chat actions" }); + const item = await within(menu).findByRole("menuitem", { + name: "Open in new window", + }); + expect(item).toContainElement(within(menu).getByTestId("window-icon")); + const names = within(menu) + .getAllByRole("menuitem") + .map((entry) => entry.textContent); + expect(names.indexOf("Open in new window")).toBe( + names.indexOf("Copy link") + 1, + ); + await user.click(item); + expect(run).toHaveBeenCalledExactlyOnceWith(id); +}); + it("runs the header's own chat actions and returns focus to Chat actions", async () => { const f = fixture(); const assign = vi.fn(async () => {}); @@ -1652,3 +1697,32 @@ it("leaves Escape to a text field outside the composer", async () => { expect(f.read.current?.available()).toBe(false); field.remove(); }); + +it("opens Share access when a pop-out window hands it to this chat's header, in this session only", async () => { + const f = fixture(); + f.mount(); + await screen.findByRole("button", { name: "Share access to Planning" }); + const session = f.session as unknown as RelaySession; + // Another chat's request, or another session's, is not this header's. + act(() => + soloAccessRequests.request({ session, scope: f.scope, channelId: "other" }), + ); + expect(screen.queryByRole("dialog", { name: /Share access/ })).toBeNull(); + act(() => + soloAccessRequests.request({ + session: {} as RelaySession, + scope: f.scope, + channelId: id, + }), + ); + expect(screen.queryByRole("dialog", { name: /Share access/ })).toBeNull(); + soloAccessRequests.clear(); + act(() => + soloAccessRequests.request({ session, scope: f.scope, channelId: id }), + ); + expect( + await screen.findByRole("dialog", { name: /Share access/ }), + ).toBeInTheDocument(); + // Taken: a later header for the same chat does not reopen it. + expect(soloAccessRequests.snapshot()).toBeUndefined(); +}); diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 619dc8dd4d..07967d6a6e 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -1,4 +1,11 @@ import { createNavigationController } from "../../features/navigation/controller"; +import type { + ComposerTool, + ConversationExtensions, + InlineRenderer, + TabAction, +} from "../../features/conversation/contracts"; +import type { Contribution } from "../../plugins/contributions"; import { createMemoryHistory } from "../../features/navigation/history"; // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; @@ -83,6 +90,7 @@ async function page( routed = false, appComponent?: RegisteredPanel["component"], soloPreferences?: RelaySession["soloPreferences"], + extensions?: ConversationExtensions, ) { const viewer = keypair(), authority = keypair(); @@ -236,6 +244,7 @@ async function page( createNavigationController(createMemoryHistory()).navigation } navigation={navigation} + extensions={extensions} /> , ); @@ -894,11 +903,40 @@ it.each(["side", "main"] as const)( it("gives a Solo chat's side tab its own header actions, opening it in the main view", async () => { const open = vi.fn(async () => ({ status: "opened" as const })); - const h = await page("stream", { - open, - snapshot: () => visitSnapshot, - subscribe: () => () => {}, - } as unknown as Navigation); + // A contributed conversation action reaches the side header too. + const run = vi.fn(); + const noTools: readonly Contribution[] = []; + const noInline: readonly Contribution[] = []; + const tabActionEntries = [ + { + id: "open-window", + key: "popout/open-window", + pluginId: "popout", + revision: "1", + title: "Open in new window", + matches: () => true, + run: (target: { channelId: string }) => run(target.channelId), + }, + ]; + const h = await page( + "stream", + { + open, + snapshot: () => visitSnapshot, + subscribe: () => () => {}, + } as unknown as Navigation, + false, + undefined, + undefined, + { + tools: { snapshot: () => noTools, subscribe: () => () => {} }, + inline: { snapshot: () => noInline, subscribe: () => () => {} }, + tabActions: { + snapshot: () => tabActionEntries, + subscribe: () => () => {}, + }, + }, + ); const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); act(() => tabs.result.current.setTabs((items) => [ @@ -922,6 +960,10 @@ it("gives a Solo chat's side tab its own header actions, opening it in the main expect( menu.queryByRole("menuitem", { name: "Convert to channel" }), ).toBeNull(); + const names = menu.getAllByRole("menuitem").map((item) => item.textContent); + expect(names.indexOf("Open in new window")).toBe( + names.indexOf("Copy link") + 1, + ); await h.user.click(menu.getByRole("menuitem", { name: "Open in main view" })); await vi.waitFor(() => expect(open).toHaveBeenLastCalledWith({ @@ -1194,3 +1236,91 @@ it("opens another conversation's tab in its channel, but not the current channel }), ); }); + +it("lists a plugin's tab action for the tabs it matches, with the exact target", async () => { + const run = vi.fn(); + const noTools: readonly Contribution[] = []; + const noInline: readonly Contribution[] = []; + type Entry = Contribution; + let entries: readonly Entry[] = [ + { + key: "popout:open", + pluginId: "popout", + revision: "1", + id: "open", + title: "Open in new window", + matches: (target) => !!target.threadRootId, + run, + }, + ]; + const listeners = new Set<() => void>(); + const tabActions = { + snapshot: () => entries, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + // A stable snapshot: the page's forward provider subscribes to navigation. + const navigationSnapshot = { entry: { id: "visit" }, attempt: {} }; + const h = await page( + "stream", + { + open: vi.fn(async () => ({ status: "opened" as const })), + snapshot: () => navigationSnapshot, + subscribe: () => () => {}, + } as unknown as Navigation, + false, + undefined, + undefined, + { + tools: { snapshot: () => noTools, subscribe: () => () => {} }, + inline: { snapshot: () => noInline, subscribe: () => () => {} }, + tabActions, + }, + ); + const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); + const root = "d".repeat(64); + act(() => { + tabs.result.current.openThreadTab("alpha", root, root, undefined, "side"); + tabs.result.current.setTabs((items) => [ + ...items, + { id: "conversation:beta", kind: "conversation", channelId: "beta" }, + ]); + }); + const menu = async (name: string) => { + await h.user.pointer({ + target: await screen.findByRole("tab", { name }), + keys: "[MouseRight]", + }); + return within(await screen.findByRole("menu")); + }; + // A whole conversation is not a thread: the matcher hides the action. + expect( + (await menu("Beta")).queryByRole("menuitem", { + name: "Open in new window", + }), + ).not.toBeInTheDocument(); + await h.user.keyboard("{Escape}"); + await h.user.click( + (await menu("Thread · Alpha")).getByRole("menuitem", { + name: "Open in new window", + }), + ); + expect(run).toHaveBeenCalledWith( + { channelId: "alpha", threadRootId: root }, + h.session, + ); + // Disabling the plugin removes its entry from the menu. + act(() => { + entries = []; + for (const listener of listeners) listener(); + }); + expect( + (await menu("Thread · Alpha")).queryByRole("menuitem", { + name: "Open in new window", + }), + ).not.toBeInTheDocument(); +}); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 2057bdb217..c18f78ad81 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1249,6 +1249,7 @@ function ChannelWorkspace({ channel.channelType === "solo" && ( )} + {active && conversationActions} {active && !regions.excluded.has(channel.id) && onConvert && diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index 06f2b447d7..86136ff37c 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -12,6 +12,7 @@ import { type RefObject, } from "react"; import { SoloTitle } from "../../features/solo/SoloPresentation"; +import { soloAccessRequests } from "../../features/solo/access-requests"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import identityStyles from "./ChannelHeaderIdentity.module.css"; import { WorkspaceSettings } from "../../features/solo/WorkspaceSettings"; @@ -38,6 +39,7 @@ import { ChannelMembersDialog, } from "./ChannelMembersDialog"; import { NewWorkspaceDialog, SoloChatMenuItems } from "./SoloChatMenu"; +import { ConversationActionItems } from "../../features/channel-navigation/ConversationActionItems"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { useSoloOwner } from "./ChatSharedMark"; @@ -46,6 +48,8 @@ const noSubscription = () => () => {}; type SoloToolbarProps = { session: RelaySession; chatMenus?: Conversation["chatMenus"] | undefined; + /** Conversation actions (Open in new window), after Copy link as in the sidebar. */ + tabActions?: Conversation["tabActions"] | undefined; scope: string; channel: ChannelSummary; navigator?: Navigation | undefined; @@ -66,6 +70,25 @@ type SoloToolbarProps = { /** The header of a conversation shown with the Solo treatment. */ export function SoloHeading(props: SoloToolbarProps) { const owner = useSoloOwner(props.session, props.channel); + // A pop-out window hands "Share access" to this header: the dialog lives + // here. The request is taken only by the header showing that chat in the + // session it was made from. + const [accessOpen, setAccessOpen] = useState(false); + const { session, scope, channel } = props; + const requested = useSyncExternalStore( + soloAccessRequests.subscribe, + soloAccessRequests.snapshot, + ); + useEffect(() => { + // Only a request for this chat, from this session, is this header's. + if ( + channel.archived || + requested?.channelId !== channel.id || + !soloAccessRequests.take(session, scope, channel.id) + ) + return; + setAccessOpen(true); + }, [requested, session, scope, channel.id, channel.archived]); return props.channel.archived ? ( @@ -81,6 +104,7 @@ export function SoloHeading(props: SoloToolbarProps) { owner={owner} navigator={props.navigator} signal={props.signal} + presentation={{ open: accessOpen, onOpenChange: setAccessOpen }} /> } actions={} @@ -93,6 +117,7 @@ export function SoloHeading(props: SoloToolbarProps) { export function SoloToolbar({ session, chatMenus, + tabActions, scope, channel, navigator, @@ -202,6 +227,15 @@ export function SoloToolbar({ onLifecycle(action, actionsTrigger.current ?? undefined) } onReadResult={setReadError} + conversationActions={ + tabActions && ( + + ) + } > {menus ?.slice() diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index 802b328fdc..dbba878027 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -85,6 +85,7 @@ export function SoloWorkspaces({ chats, renderChat, renderChatMenu, + renderConversationActions, onNew, onConverted, visit, @@ -100,6 +101,8 @@ export function SoloWorkspaces({ chats: readonly ChatListItem[]; sidebarRows?: readonly RegisteredSidebarRows[] | undefined; renderChatMenu?: ((channelId: string) => ReactNode) | undefined; + /** Contributed conversation actions, placed after Copy link in a chat's menu. */ + renderConversationActions?: ((channelId: string) => ReactNode) | undefined; /** A stable function lets a chat's row skip renders when nothing it shows changed. */ renderChat: ( item: ChatListItem, @@ -631,6 +634,7 @@ export function SoloWorkspaces({ renderChatMenu={renderChatMenu} sidebarRows={sidebarRows} onHiddenChange={setRowHidden} + renderConversationActions={renderConversationActions} /> ); })} @@ -839,6 +843,7 @@ const SoloChatEntry = memo(function SoloChatEntry({ renderChatMenu, sidebarRows, onHiddenChange, + renderConversationActions, }: { id: string; title: string; @@ -854,6 +859,7 @@ const SoloChatEntry = memo(function SoloChatEntry({ renderChatMenu: ((channelId: string) => ReactNode) | undefined; sidebarRows: readonly RegisteredSidebarRows[]; onHiddenChange: (id: string, hidden: boolean) => () => void; + renderConversationActions: ((channelId: string) => ReactNode) | undefined; }) { const rename = renameable ? () => actions.current.rename(id) : undefined; const Default = ( @@ -887,6 +893,7 @@ const SoloChatEntry = memo(function SoloChatEntry({ : undefined } onReadResult={(error) => actions.current.readResult(id, error)} + conversationActions={renderConversationActions?.(id)} /> ) diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 29938adc7c..51048b4eca 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -12,7 +12,11 @@ import { import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import type { ChannelSummary, Profile } from "../../features/relay/contracts"; -import type { ConversationExtensions } from "../../features/conversation/contracts"; +import type { + ConversationExtensions, + TabAction, +} from "../../features/conversation/contracts"; +import type { Contribution } from "../../plugins/contributions"; import { panelOpening, type PanelOpening, @@ -50,6 +54,10 @@ import { import styles from "./Channels.module.css"; import tabStyles from "./ChannelTabs.module.css"; +const noSubscription = () => () => {}; +const noContributions = (): readonly Contribution[] => none; +const none: readonly Contribution[] = []; + /** The content-tab families whose visits follow explicit dismissal. */ export const historyPanel = (entry: PanelOpening) => !entry.channelContext && @@ -809,6 +817,31 @@ export function useConversationTabs({ // A thread names its conversation as Inbox rows do, bounding a group DM's // names where its tab lists them all; the name opens it there. const resolveName = useIdentityNames(queries.names); + // Plugin tab actions follow the host's own in each conversation tab's menu. + const tabActions = extensions?.tabActions; + const contributedActions = useSyncExternalStore( + tabActions?.subscribe ?? noSubscription, + tabActions?.snapshot ?? noContributions, + ); + function actionsFor(tab: Tab, openChannel: (() => void) | undefined) { + const target = { + channelId: tab.channelId, + ...(tab.kind === "thread" && { threadRootId: rootOfTab(tab) }), + }; + const actions = [ + ...(openChannel ? [{ label: "Open in channel", run: openChannel }] : []), + ...contributedActions.flatMap((action) => { + try { + return action.matches(target, queries) + ? [{ label: action.title, run: () => action.run(target, queries) }] + : []; + } catch { + return []; // A failing contribution hides itself, not the menu. + } + }), + ]; + return actions.length ? { actions } : {}; + } function parentOf(tab: Tab, target: ChannelSummary) { const open = openInChannel(tab, target); return ( @@ -847,9 +880,7 @@ export function useConversationTabs({ move: (delta: -1 | 1) => moveBy(tab, delta), rename: (name: string) => tabState.renameTab(tab.id, name), }), - ...(openChannel && { - actions: [{ label: "Open in channel", run: openChannel }], - }), + ...actionsFor(tab, openChannel), content: !usable || !target ? (

diff --git a/src/bundled/github/SoloPull.test.tsx b/src/bundled/github/SoloPull.test.tsx index 953894a586..22c8b3b932 100644 --- a/src/bundled/github/SoloPull.test.tsx +++ b/src/bundled/github/SoloPull.test.tsx @@ -12,6 +12,7 @@ import { apply } from "./index"; import { setOpenGitHubLinksInBuzz } from "./link-preference"; import type { SoloPull, SoloPulls } from "./solo-pull"; import { + describeSoloPullIcon, SoloPullIcon, SoloPullLink, SoloPullMenu, @@ -396,3 +397,56 @@ it("only mounts the PR shortcut for a Solo in the current community", () => { screen.getByRole("button", { name: "Open linked pull request" }), ).toBeVisible(); }); + +it("describes the chat icon in data as the component draws it, for a host that cannot run it", () => { + expect(describeSoloPullIcon(undefined)).toBeUndefined(); + expect( + describeSoloPullIcon({ + url: pull, + state: { state: "open", checks: "failure", conflicts: true }, + }), + ).toEqual({ + icon: "git-pull-request", + tone: "failure", + label: "Pull request, open, checks failing, conflicts", + }); + expect( + describeSoloPullIcon({ url: pull, state: { state: "merged" } }), + ).toEqual({ + icon: "git-merge", + tone: "merged", + label: "Pull request, merged", + }); + expect(describeSoloPullIcon({ url: pull, failed: true })).toEqual({ + icon: "git-pull-request", + tone: "unknown", + label: "Pull request, unavailable", + }); +}); + +it("registers the chat icon with a describer that reads and watches the Solo chat's pull request", () => { + let describer: + | import("../../features/conversation/service").ChatIconDescriber + | undefined; + const inject = vi.fn(); + const relay = { + snapshot: () => ({ status: "idle" }), + subscribe: () => () => {}, + } as unknown as RelayData; + apply({ + host: {}, + panels: { register: () => {} }, + conversation: { + registerChatIcon: (_component: unknown, given: typeof describer) => { + describer = given; + }, + registerChatMenu: () => {}, + registerLink: () => {}, + }, + relay, + agents: { addTool: () => {}, relay: () => relay }, + inject, + } as never); + expect(describer?.describe).toBeTypeOf("function"); + expect(describer?.watch).toBeTypeOf("function"); +}); diff --git a/src/bundled/github/SoloPull.tsx b/src/bundled/github/SoloPull.tsx index 800978165b..d9660fbc55 100644 --- a/src/bundled/github/SoloPull.tsx +++ b/src/bundled/github/SoloPull.tsx @@ -6,12 +6,15 @@ import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import type { ChannelLauncherProps } from "../../features/panels/service"; import { useRelayConnection } from "../../features/relay/react"; -import type { ChatIconProps } from "../../features/conversation/service"; +import type { + ChatIconDescription, + ChatIconProps, +} from "../../features/conversation/service"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import { useChannelList } from "../../features/relay/react"; import { ownSolo } from "../../features/solo/labels"; -import { writeLink, type SoloPulls } from "./solo-pull"; +import { writeLink, type SoloPull, type SoloPulls } from "./solo-pull"; import { describePull, pullTone, stateIcons } from "./pull-state-view"; import styles from "./GitHub.module.css"; import { useOpenGitHubLinksInBuzz } from "./link-preference"; @@ -105,6 +108,23 @@ export function SessionPullLauncher({ ); } +/** `SoloPullIcon` as a host draws it from data: the state's icon name, its + * tone and the words; none for a chat without a linked pull request. */ +export function describeSoloPullIcon( + pull: SoloPull | undefined, +): ChatIconDescription | undefined { + if (!pull) return undefined; + const state = pull.state?.state ?? "open"; + const icons: Record = { + open: "git-pull-request", + draft: "circle-dashed", + closed: "x-circle", + merged: "git-merge", + }; + const icon = icons[state]; + return { icon, tone: pullTone(pull), label: describePull(pull) }; +} + function useSoloPull(pulls: SoloPulls, channelId: string) { const subscribe = useCallback( (listener: () => void) => pulls.watch(channelId, listener), diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index c6e9fb107b..b1ba0294e1 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -1,3 +1,4 @@ +import { describeSoloPullIcon } from "./SoloPull"; import { ResolvePull } from "./ResolvePull"; import type { PullChecklist as Checklist } from "./pull"; import { Button } from "../../shared/design-system/ui/Button"; @@ -151,13 +152,22 @@ export const apply: PluginModule["apply"] = (ctx) => { channelId={channelId} /> )); - ctx.conversation.registerChatIcon(({ session, channelId, fallback }) => ( - - )); + ctx.conversation.registerChatIcon( + ({ session, channelId, fallback }) => ( + + ), + // The same icon in data, for a pop-out window's header. + { + describe: (session, channelId) => + describeSoloPullIcon(pullsFor(session).get(channelId)), + watch: (session, channelId, listener) => + pullsFor(session).watch(channelId, listener), + }, + ); ctx.agents.addTool({ name: "github_link_pr", description: diff --git a/src/bundled/huddles/HuddleComposer.tsx b/src/bundled/huddles/HuddleComposer.tsx index 4e402200b5..25559d7ca1 100644 --- a/src/bundled/huddles/HuddleComposer.tsx +++ b/src/bundled/huddles/HuddleComposer.tsx @@ -1,12 +1,96 @@ import { MentionModelsContext } from "../../features/agents/react"; -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { createHuddleComposerClient } from "../../features/huddle/composer-client"; +import { + attachmentDraft, + attachmentDraftCount, + subscribeAttachmentDraftCounts, + transferAttachmentDraft, +} from "../../features/messages/attachment-draft"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { huddleComposerExtensions } from "./composer-extensions"; +import type { ChannelMessage } from "../../features/relay/contracts"; +import type { ComposerSession } from "../../features/messages/composer-session"; -export function useHuddleComposer(token: string | undefined) { - const [client, setClient] = - useState>(); +const noEdits: readonly ChannelMessage[] = []; + +/** + * Files a companion window keeps for an editor that is not mounted right now: + * a pop-out that drilled from its conversation into a thread still holds the + * conversation's files (drafts are keyed by the editor's session object, + * which a companion rebuilds per connection), and returns them to the editor + * that mounts again for the same draft key. Never evicted: each entry is + * unsent work the person attached by hand, and the window's close guard + * counts it; the window's lifetime bounds the set. + */ +const retainedDrafts = new Map< + string, + { session: ComposerSession; channelId: string } +>(); +const retainedListeners = new Set<() => void>(); +const retainedChanged = () => { + for (const listener of retainedListeners) listener(); +}; +function retainDraft(key: string, session: ComposerSession, channelId: string) { + retainedDrafts.set(key, { session, channelId }); + retainedChanged(); +} +/** Files held for other editors of this window: still unsent work. */ +function useRetainedDraftCount(except: string) { + const subscribe = useCallback((listener: () => void) => { + retainedListeners.add(listener); + const stops = [...retainedDrafts.values()].map(({ session }) => + subscribeAttachmentDraftCounts(session, listener), + ); + return () => { + retainedListeners.delete(listener); + for (const stop of stops) stop(); + }; + }, []); + return useSyncExternalStore(subscribe, () => { + let count = 0; + for (const [key, { session }] of retainedDrafts) + if (key !== except) count += attachmentDraftCount(session, key); + return count; + }); +} + +/** How the hosting window presents the editor; the Huddle defaults apply. */ +export type CompanionComposerPresentation = { + channelName?: string; + label?: string; + placeholder?: string; + onHeld?: ((held: boolean) => void) | undefined; + /** Reply to this message within the thread (pop-out windows). */ + replyParentId?: string | undefined; + replyContext?: ReactNode; + /** Messages the editor may edit (pop-out windows); Huddle edits nothing. */ + editMessages?: readonly ChannelMessage[] | undefined; + /** A session wrapping the bridge client's with more operations (pop-out + * windows: the operations outbox, so edits follow one id to delivery). */ + session?: ComposerSession | undefined; + /** This window may record voice notes (pop-out windows; never the Huddle). */ + capture?: boolean | undefined; + /** Told while a voice note is being captured in this editor. */ + onCapturing?: ((capturing: boolean) => void) | undefined; +}; +export type CompanionComposerClient = ReturnType< + typeof createHuddleComposerClient +>; +/** The connected editor plus its bridge client, for hosts that also react or + * otherwise operate through the same main-window owner. */ +export function useCompanionComposer( + token: string | undefined, + presentation?: CompanionComposerPresentation, +) { + const [client, setClient] = useState(); useEffect(() => { if (!token) { setClient(undefined); @@ -16,12 +100,25 @@ export function useHuddleComposer(token: string | undefined) { setClient(next); return () => next.dispose(); }, [token]); - return client ? : null; + return { + client, + composer: client ? ( + + ) : null, + }; +} +export function useHuddleComposer( + token: string | undefined, + presentation?: CompanionComposerPresentation, +) { + return useCompanionComposer(token, presentation).composer; } function ConnectedComposer({ client, + presentation, }: { client: ReturnType; + presentation?: CompanionComposerPresentation | undefined; }) { useSyncExternalStore(client.subscribe, client.revision); const { session, data, error } = client.snapshot(); @@ -30,28 +127,97 @@ function ConnectedComposer({ void modelRevision; return (keys: readonly string[]) => client.models(keys); }, [client, modelRevision]); - const keys = JSON.stringify([data?.tools ?? [], data?.completions ?? []]); + const keys = JSON.stringify([ + data?.tools ?? [], + data?.completions ?? [], + data?.links ?? [], + ]); + const capture = presentation?.capture ?? false; const extensions = useMemo(() => { - const [tools, completions] = JSON.parse(keys) as [string[], string[]]; + const [tools, completions, links] = JSON.parse(keys) as [ + string[], + string[], + string[], + ]; return huddleComposerExtensions({ tools, completions, + links, gifRequests: client.gifs, + capture, }); - }, [keys, client]); + }, [keys, client, capture]); + // Held work is the editor's unsaved state or files kept only in this window. + const [editorHeld, setEditorHeld] = useState(false); + const draftKey = data + ? `${data.scope}:${data.thread ? `draft:${data.room}:thread:${data.thread}` : `draft:${data.room}`}` + : ""; + // Drafts are keyed by the session the editor was given, which a host may + // wrap; count against that one, or the files it keeps go unguarded. + const editorSession = session + ? (presentation?.session ?? session) + : undefined; + const files = useSyncExternalStore( + useCallback( + (listener: () => void) => + editorSession + ? subscribeAttachmentDraftCounts(editorSession, listener) + : () => {}, + [editorSession], + ), + () => (editorSession ? attachmentDraftCount(editorSession, draftKey) : 0), + ); + // Files kept for this draft by an earlier editor session of this window + // return to this one; on unmount, this editor's files are kept the same way. + const room = data?.room; + useEffect(() => { + if (!editorSession || !draftKey || !room) return; + const kept = retainedDrafts.get(draftKey); + if (kept && kept.session !== editorSession) { + const source = attachmentDraft(kept.session, draftKey, kept.channelId); + if (source.snapshot().length) + try { + transferAttachmentDraft( + source, + attachmentDraft(editorSession, draftKey, room), + ); + } catch { + /* The new draft already holds files; the kept ones stay kept. */ + } + } + if (kept) { + retainedDrafts.delete(draftKey); + retainedChanged(); + } + return () => { + if (attachmentDraftCount(editorSession, draftKey) > 0) + retainDraft(draftKey, editorSession, room); + }; + }, [editorSession, draftKey, room]); + const retainedFiles = useRetainedDraftCount(draftKey); + const onHeld = presentation?.onHeld; + const held = editorHeld || files > 0 || retainedFiles > 0; + useEffect(() => { + onHeld?.(held); + }, [onHeld, held]); if (error) return

{error}

; return session && data ? ( ) : ( diff --git a/src/bundled/huddles/HuddleDiscussionView.tsx b/src/bundled/huddles/HuddleDiscussionView.tsx index 16573123fb..68a4d55d15 100644 --- a/src/bundled/huddles/HuddleDiscussionView.tsx +++ b/src/bundled/huddles/HuddleDiscussionView.tsx @@ -1,8 +1,6 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import type { HuddleDiscussion } from "../../features/huddle/discussion"; -import { Dialog } from "../../shared/design-system/ui/Dialog"; -import { ImageReviewStage } from "../../features/messages/ImageReviewStage"; -import { MessageRow } from "../../features/messages/MessageRow"; +import { CompanionRows } from "../../features/companion/CompanionRows"; import { Tabs } from "../../shared/design-system/ui/Tabs"; import { Button } from "../../shared/design-system/ui/Button"; @@ -29,11 +27,6 @@ export function HuddleDiscussionView({ recover(id: string, dismiss?: boolean): void; }) { const panelId = useId(); - const [image, setImage] = useState<{ rowId: string; url: string }>(); - const imageRow = discussion.rows.find((row) => row.id === image?.rowId); - const selectedImage = imageRow?.attachments.find( - (attachment) => attachment.url === image?.url, - ); const [tab, setTab] = useState<"thread" | "transcript">("thread"); const tail = useRef(null); const log = useRef(null); @@ -112,94 +105,14 @@ export function HuddleDiscussionView({ ) : discussion.status === "ready" && !discussion.error ? ( } title="No messages yet" /> ) : null)} - {discussion.rows.map((row) => ( -
- [key, { name }], - ), - ) - } - profile={{ - name: row.author, - ...(row.pictureUrl ? { picture: row.pictureUrl } : {}), - }} - media={(url) => { - if (url === row.pictureUrl) return row.picture ?? undefined; - for (const attachment of row.attachments) { - if (url === attachment.url) - return attachment.source ?? undefined; - if (url === attachment.previewUrl) - return attachment.previewSource ?? undefined; - } - return undefined; - }} - canOpenLink={(url) => - row.attachments.some( - (attachment) => - attachment.kind === "image" && attachment.url === url, - ) - } - onOpenLink={(url) => { - if ( - !row.attachments.some( - (attachment) => - attachment.kind === "image" && attachment.url === url, - ) - ) - return openLink?.(url) ?? false; - setImage({ rowId: row.id, url }); - return true; - }} - day={false} - retry={undefined} - layout="thread" - /> - {["failed", "unknown"].includes(row.delivery) && ( -

- {row.delivery === "unknown" - ? "Delivery hasn’t been confirmed. Retry sends the same message." - : "Message wasn’t delivered."}{" "} - {" "} - -

- )} -
- ))} +
{discussion.error && ( @@ -228,32 +141,6 @@ export function HuddleDiscussionView({ ) )} - { - if (!open) setImage(undefined); - }} - title={selectedImage?.name ?? "Image attachment"} - size="expanded" - height="stable" - bodyLayout="flex" - > - {imageRow && selectedImage && ( - attachment.kind === "image", - )} - selectedUrl={selectedImage.url} - media={(url) => - imageRow.attachments.find((attachment) => attachment.url === url) - ?.source ?? undefined - } - select={(url) => setImage({ rowId: imageRow.id, url })} - onOpenLink={() => false} - /> - )} - ); } diff --git a/src/bundled/huddles/composer-extensions.test.tsx b/src/bundled/huddles/composer-extensions.test.tsx new file mode 100644 index 0000000000..5d57527233 --- /dev/null +++ b/src/bundled/huddles/composer-extensions.test.tsx @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { huddleComposerExtensions } from "./composer-extensions"; + +const keys = (extensions: ReturnType) => + extensions.tools.snapshot().map((tool) => tool.key); + +it("offers the voice recorder only to a window that may capture, and only while main enables it", () => { + const enabled = ["buzz.emoji/picker", "buzz.voice-notes/recorder"]; + expect( + keys(huddleComposerExtensions({ tools: enabled, completions: [] })), + ).toEqual(["buzz.emoji/picker"]); + expect( + keys( + huddleComposerExtensions({ + tools: enabled, + completions: [], + capture: true, + }), + ), + ).toEqual(enabled); + expect( + keys( + huddleComposerExtensions({ + tools: ["buzz.emoji/picker"], + completions: [], + capture: true, + }), + ), + ).toEqual(["buzz.emoji/picker"]); +}); + +it("reuses the bundled link renderer only while main enables it; none by default", () => { + const links = (extensions: ReturnType) => + extensions.links?.snapshot().map((link) => link.key); + expect( + links(huddleComposerExtensions({ tools: [], completions: [] })), + ).toEqual([]); + expect( + links( + huddleComposerExtensions({ + tools: [], + completions: [], + links: ["buzz.links/links", "buzz.github/pull"], + }), + ), + ).toEqual(["buzz.links/links"]); +}); diff --git a/src/bundled/huddles/composer-extensions.tsx b/src/bundled/huddles/composer-extensions.tsx index dc38ab60ff..2cfb649ee0 100644 --- a/src/bundled/huddles/composer-extensions.tsx +++ b/src/bundled/huddles/composer-extensions.tsx @@ -2,10 +2,12 @@ import type { ConversationExtensions, ComposerTool, ComposerCompletion, + LinkRenderer, } from "../../features/conversation/contracts"; import type { Contribution } from "../../plugins/contributions"; import type { GifRequests } from "../../features/relay/gifs"; import { EmojiPicker } from "../emoji/EmojiPicker"; +import { createReactionPicker } from "../emoji/ReactionPicker"; import { EmojiCompletion } from "../emoji/EmojiCompletion"; import { emojiQuery } from "../emoji/emoji-query"; import { MentionPicker } from "../mentions/MentionPicker"; @@ -14,9 +16,14 @@ import { mentionQuery } from "../mentions/mention-query"; import { ChannelCompletion } from "../channels/ChannelCompletion"; import { channelQuery } from "../channels/channel-query"; import { ResourcePicker } from "../repos/ResourcePicker"; +import { voiceRecorderTool } from "../voice-notes/recorder-tool"; +import { linkRenderer } from "../links"; /** Native companions reuse bundled tools only while their main-app contribution is enabled. */ -const tools = (gifRequests?: GifRequests): Record => ({ +const tools = ( + gifRequests?: GifRequests, + capture = false, +): Record => ({ "buzz.emoji/picker": { id: "picker", title: "Emoji", @@ -29,6 +36,8 @@ const tools = (gifRequests?: GifRequests): Record => ({ insert={insertText} /> ), + // Pop-out windows react with the same picker the main window uses. + reactionComponent: createReactionPicker(), }, "buzz.mentions/picker": { id: "picker", @@ -57,6 +66,9 @@ const tools = (gifRequests?: GifRequests): Record => ({ title: "Issues and pull requests", component: ResourcePicker, }, + // A window that may capture records in its own webview; main converts and + // uploads the note like any companion attachment. Huddles never capture. + ...(capture ? { "buzz.voice-notes/recorder": voiceRecorderTool } : {}), }); const completions: Record = { "buzz.emoji/typeahead": { @@ -80,10 +92,18 @@ const completions: Record = { component: ChannelCompletion, }, }; +// Buzz-entity and service link labels, in messages and composer decorations. +const links: Record = { + "buzz.links/links": linkRenderer, +}; export function huddleComposerExtensions(enabled: { tools: readonly string[]; completions: readonly string[]; + /** Enabled link renderers, as main projects them; none by default. */ + links?: readonly string[]; gifRequests?: GifRequests; + /** This window may record voice notes (pop-outs; never the Huddle). */ + capture?: boolean; }): ConversationExtensions { function reader(catalog: Record, keys: readonly string[]) { const entries: Contribution[] = keys.flatMap((key) => @@ -101,8 +121,9 @@ export function huddleComposerExtensions(enabled: { return { snapshot: () => entries, subscribe: () => () => {} }; } return { - tools: reader(tools(enabled.gifRequests), enabled.tools), + tools: reader(tools(enabled.gifRequests, enabled.capture), enabled.tools), completions: reader(completions, enabled.completions), inline: reader({}, []), + links: reader(links, enabled.links ?? []), }; } diff --git a/src/bundled/huddles/window-entry.tsx b/src/bundled/huddles/window-entry.tsx index 56578d183f..8988fe77d4 100644 --- a/src/bundled/huddles/window-entry.tsx +++ b/src/bundled/huddles/window-entry.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { Channel, invoke } from "@tauri-apps/api/core"; import type { HuddleAction, HuddleView } from "../../features/huddle/window"; import { HuddleWindowView } from "./HuddleWindowView"; +import { applyDocumentAppearance } from "../../shared/theme/document-appearance"; import { useKeyboardFocusVisibility } from "../../shared/design-system/useKeyboardFocusVisibility"; import { clearReactDevMeasures } from "../../features/developer/react-dev-timing"; @@ -23,7 +24,9 @@ function Companion() { const updates = new Channel(); updates.onmessage = (next) => { if (!mounted) return; - document.documentElement.classList.toggle("dark", next.dark); + applyDocumentAppearance( + next.appearance ?? { colorMode: next.dark ? "dark" : "light" }, + ); setView((previous) => ({ ...next, discussion: next.discussionOpen @@ -59,7 +62,15 @@ function Companion() { } const container = document.getElementById("root"); if (container) { + // The window is the viewport: only the message list scrolls. Without this + // a transient overflow (a tall composer, a notice) scrolls the document and + // leaves the header off-screen above an empty page. + for (const element of [document.documentElement, document.body]) { + element.style.height = "100%"; + element.style.overflow = "hidden"; + } container.style.height = "100dvh"; + container.style.overflow = "hidden"; createRoot(container).render( diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 1af5b86862..b6be132a76 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -10,6 +10,8 @@ import * as pairing from "./pairing"; import todosManifest from "./todos/manifest.json"; import huddlesManifest from "./huddles/manifest.json"; import * as huddles from "./huddles"; +import popoutManifest from "./popout/manifest.json"; +import * as popout from "./popout"; import builderlabManifest from "./builderlab/manifest.json"; import * as builderlab from "./builderlab"; import * as todos from "./todos"; @@ -101,6 +103,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: huddles, enabledByDefault: true, }, + { + manifest: { ...popoutManifest, apiVersion: 1 }, + module: popout, + enabledByDefault: true, + }, { manifest: { ...voiceNotesManifest, apiVersion: 1 }, module: voiceNotes, diff --git a/src/bundled/links/index.tsx b/src/bundled/links/index.tsx index 275fb0163e..5a31374dfe 100644 --- a/src/bundled/links/index.tsx +++ b/src/bundled/links/index.tsx @@ -1,17 +1,22 @@ import type { PluginModule } from "../../plugins/api"; +import type { LinkRenderer } from "../../features/conversation/contracts"; import { LinkLabel, linkKind } from "./InlineLink"; import styles from "../../shared/InlineReference.module.css"; +/** The renderer this plugin registers; companion windows reuse it while main + * has it enabled. */ +export const linkRenderer: LinkRenderer = { + id: "links", + title: "Links", + matches: (url) => linkKind(url) !== null, + // Specific for recognised services and Buzz entities; a catch-all for the + // globe case, so a dedicated renderer wins unknown hosts at the default band. + order: (url) => (linkKind(url) === "web" ? 100 : 0), + className: styles.link, + component: ({ url }) => , +}; + export const inject = ["conversation"]; export const apply: PluginModule["apply"] = (ctx) => { - ctx.conversation.registerLink({ - id: "links", - title: "Links", - matches: (url) => linkKind(url) !== null, - // Specific for recognised services and Buzz entities; a catch-all for the - // globe case, so a dedicated renderer wins unknown hosts at the default band. - order: (url) => (linkKind(url) === "web" ? 100 : 0), - className: styles.link, - component: ({ url }) => , - }); + ctx.conversation.registerLink(linkRenderer); }; diff --git a/src/bundled/popout/Popout.module.css b/src/bundled/popout/Popout.module.css new file mode 100644 index 0000000000..3b75d2fb70 --- /dev/null +++ b/src/bundled/popout/Popout.module.css @@ -0,0 +1,108 @@ +.window { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--surface-panel); + color: var(--text-standard); +} +.header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-standard); + flex-shrink: 0; +} +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex: 0 0 1.25rem; + color: var(--text-secondary); +} +.members { + display: inline-flex; + align-items: center; + flex: none; + min-height: var(--size-control-sm); + padding: 0 var(--space-2); + border: 0; + border-radius: var(--radius-row); + background: transparent; + color: var(--text-standard); + cursor: pointer; +} +.members:hover { + background: var(--affordance-subtle-hover); +} +.members:active { + background: var(--affordance-subtle-pressed); +} +/* Plugin chat icon tones, as the sidebar colours them: open is success, + failing checks or conflicts danger, pending warning; the rest quiet. */ +.icon[data-tone="open"] { + color: var(--icon-success); +} +.icon[data-tone="failure"] { + color: var(--text-danger); +} +.icon[data-tone="pending"] { + color: var(--text-warning); +} +.icon[data-tone="merged"] { + color: var(--icon-categorical-purple); +} +.title { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + letter-spacing: var(--text-heading--letter-spacing); +} +.kind { + font-weight: var(--type-weight-medium); +} +.conversation { + margin-left: var(--space-2); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + font-weight: var(--type-weight-normal); + color: var(--text-secondary); +} +.messages { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--space-4); +} +.notice { + padding: var(--space-3) var(--space-4); + font-size: var(--text-body-sm); + color: var(--text-secondary); +} +.composer { + flex-shrink: 0; +} +.replyContext { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--text-body-sm); + color: var(--text-secondary); +} + +.typing { + margin: var(--space-2) 0 0; + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + color: var(--text-secondary); +} diff --git a/src/bundled/popout/PopoutWindowView.composer.test.tsx b/src/bundled/popout/PopoutWindowView.composer.test.tsx new file mode 100644 index 0000000000..d798cbec75 --- /dev/null +++ b/src/bundled/popout/PopoutWindowView.composer.test.tsx @@ -0,0 +1,433 @@ +// @vitest-environment jsdom +import { + act, + cleanup as cleanupReact, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createHuddleComposerClient } from "../../features/huddle/composer-client"; +import { createHuddleComposerOwner } from "../../features/huddle/composer-owner"; +import { composerDOMFixture } from "../../features/messages/composer-testing"; +import type { PopoutView } from "../../features/popout/window"; +import { createRelaySession } from "../../features/relay/session"; +import { useHuddleComposer } from "../huddles/HuddleComposer"; +import { linkRenderer } from "../links"; +import { PopoutWindowView } from "./PopoutWindowView"; + +// The pop-out editor through the real owner/client bridge: what main +// projects (tools, uploads) is what the pop-out's composer offers. +composerDOMFixture(); +const cleanup: (() => void)[] = []; +beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // Node's BroadcastChannel loses File identity; deliver in order instead. + const peers = new Set<{ + name: string; + onmessage?: (event: { data: unknown }) => void; + }>(); + vi.stubGlobal( + "BroadcastChannel", + class { + onmessage?: (event: { data: unknown }) => void; + constructor(readonly name: string) { + peers.add(this); + } + postMessage(data: unknown) { + for (const peer of peers) + if (peer !== this && peer.name === this.name) + queueMicrotask(() => { + if (peers.has(peer)) peer.onmessage?.({ data }); + }); + } + close() { + peers.delete(this); + } + }, + ); +}); +afterEach(() => { + cleanupReact(); + for (const fn of cleanup.splice(0).reverse()) fn(); + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +function harness() { + const store = createRelaySession(null); + const viewer = "ab".repeat(32); + const room = "room"; + const scope = `https://example.test:${viewer}`; + const metadata = { + id: room, + name: "Design", + huddle: true as const, + parentChannelId: "parent", + members: [viewer], + }; + const session = { + ...store.session, + viewer, + scope, + channels: { ...store.session.channels, get: () => metadata }, + attachments: { + upload: vi.fn(async (file: File) => ({ + name: file.name, + url: `https://example.test/media/${file.name}`, + type: file.type, + size: file.size, + sha256: "ab".repeat(32), + })), + }, + messages: { ...store.session.messages, send: vi.fn(() => "id") }, + }; + const owner = createHuddleComposerOwner( + session, + room, + "parent", + { + tools: { + snapshot: () => [ + { + key: "buzz.voice-notes/recorder", + id: "recorder", + title: "Voice Notes", + pluginId: "buzz.voice-notes", + revision: "bundled", + component: () => null, + }, + ], + subscribe: () => () => {}, + }, + }, + () => true, + ); + cleanup.push( + () => store.dispose(), + () => owner.dispose(), + ); + return { owner, scope, room, session }; +} +const view = (h: ReturnType): PopoutView => ({ + title: "Design", + scope: h.scope, + dark: false, + status: "ready", + writable: true, + canLoadMore: false, + limited: false, + loadingOlder: false, + reading: undefined, + readingRevision: 0, + operations: [], + following: false, + isDm: false, + kind: "channel", + thread: true, + version: 1, + root: { + id: "1".repeat(64), + author: "Ada", + authorId: "a".repeat(64), + channelId: h.room, + attachments: [], + picture: null, + text: "Root", + time: 1_700_000_000, + delivery: "seen", + }, + replies: [], + composer: h.owner.token, +}); + +it("offers the recorder main enabled in the pop-out's editor, and guards a file it keeps", async () => { + const h = harness(); + const client = createHuddleComposerClient(h.owner.token); + cleanup.push(() => client.dispose()); + await vi.waitFor(() => + expect(client.snapshot().data?.tools).toContain( + "buzz.voice-notes/recorder", + ), + ); + expect(client.snapshot().data?.uploads).toBe(true); + const act = vi.fn(); + const mounted = render(); + await screen.findByRole("textbox", { name: "Reply to thread" }); + expect( + screen.getByRole("button", { name: "Record voice note" }), + ).toBeTruthy(); + const input = mounted.container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing file picker"); + fireEvent.change(input, { + target: { files: [new File(["note"], "note.txt", { type: "text/plain" })] }, + }); + await screen.findByRole("button", { name: "Remove note.txt" }); + // The retained file is unsent work of this window: native asks before close. + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); +}); + +it("never offers the recorder to the Huddle companion", async () => { + const h = harness(); + function Companion() { + return useHuddleComposer(h.owner.token); + } + render(); + await screen.findByRole("textbox", { name: "Message this Huddle" }); + expect( + screen.queryByRole("button", { name: "Record voice note" }), + ).toBeNull(); +}); + +it("keeps a conversation's files and close protection while drilled into a thread, and restores them on back", async () => { + const h = harness(); + // The same main session owns the thread frame's composer. + const threadOwner = createHuddleComposerOwner( + h.session, + h.room, + h.room, + undefined, + () => true, + () => true, + undefined, + "1".repeat(64), + ); + cleanup.push(() => threadOwner.dispose()); + const act = vi.fn(); + const conversation = { ...view(h), thread: false, root: undefined }; + const thread = { + ...view(h), + thread: true, + canGoBack: true, + composer: threadOwner.token, + }; + const mounted = render(); + await screen.findByRole("textbox", { name: "Message #Design" }); + const input = mounted.container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing file picker"); + fireEvent.change(input, { + target: { + files: [new File(["unsent"], "kept.txt", { type: "text/plain" })], + }, + }); + await screen.findByRole("button", { name: "Remove kept.txt" }); + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); + // Drill into a thread: the conversation's file is still this window's + // unsent work, so the close guard stays up. + mounted.rerender(); + await screen.findByRole("textbox", { name: "Reply to thread" }); + expect(screen.queryByRole("button", { name: "Remove kept.txt" })).toBeNull(); + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); + // Back: the file is where it was left. + mounted.rerender(); + await screen.findByRole("textbox", { name: "Message #Design" }); + expect( + await screen.findByRole("button", { name: "Remove kept.txt" }), + ).toBeTruthy(); + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); +}); + +it("keeps every retained draft: the first of nine threads' files is still there when it is reopened", async () => { + const h = harness(); + const threadOwners = Array.from({ length: 9 }, (_, i) => { + const owner = createHuddleComposerOwner( + h.session, + h.room, + h.room, + undefined, + () => true, + () => true, + undefined, + String(i + 1).padStart(64, "0"), + ); + cleanup.push(() => owner.dispose()); + return owner; + }); + const act = vi.fn(); + const conversation = { ...view(h), thread: false, root: undefined }; + const threadView = (i: number) => ({ + ...view(h), + thread: true, + canGoBack: true, + composer: threadOwners[i]?.token, + }); + const mounted = render(); + await screen.findByRole("textbox", { name: "Message #Design" }); + for (let i = 0; i < 9; i++) { + mounted.rerender(); + await screen.findByRole("textbox", { name: "Reply to thread" }); + const input = mounted.container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing file picker"); + fireEvent.change(input, { + target: { + files: [new File(["t"], `thread-${i + 1}.txt`, { type: "text/plain" })], + }, + }); + await screen.findByRole("button", { name: `Remove thread-${i + 1}.txt` }); + mounted.rerender(); + await screen.findByRole("textbox", { name: "Message #Design" }); + } + mounted.rerender(); + expect( + await screen.findByRole("button", { name: "Remove thread-1.txt" }), + ).toBeTruthy(); + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); +}); + +it("asks before leaving a view while a voice note is being recorded", async () => { + const track = { stop: vi.fn(), kind: "audio" }; + vi.stubGlobal("navigator", { + ...navigator, + mediaDevices: { + getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), + }, + }); + class Recorder extends EventTarget { + state = "inactive"; + static isTypeSupported = () => true; + start() { + this.state = "recording"; + } + stop() { + this.state = "inactive"; + this.dispatchEvent(new Event("stop")); + } + } + vi.stubGlobal("MediaRecorder", Recorder); + vi.stubGlobal( + "AudioContext", + class { + createAnalyser = () => ({ fftSize: 0, smoothingTimeConstant: 0 }); + createMediaStreamSource = () => ({ connect: () => {} }); + close = async () => {}; + }, + ); + const h = harness(); + const act = vi.fn(); + const root = view(h).root as NonNullable; + const conversation: PopoutView = { + ...view(h), + thread: false, + root: undefined, + replies: [{ ...root, replyCount: 2 }], + }; + render(); + await screen.findByRole("textbox", { name: "Message #Design" }); + fireEvent.click( + await screen.findByRole("button", { name: "Record voice note" }), + ); + await waitFor(() => expect(act).toHaveBeenLastCalledWith("guard", "1")); + fireEvent.click( + screen.getByRole("button", { name: /View thread: 2 replies/ }), + ); + expect(act).not.toHaveBeenCalledWith("openThread", expect.anything()); + expect(screen.getByText("Discard your recording?")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Keep recording" })); + expect(act).not.toHaveBeenCalledWith("openThread", expect.anything()); + expect(track.stop).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("button", { name: /View thread: 2 replies/ }), + ); + fireEvent.click(screen.getByRole("button", { name: "Discard and continue" })); + expect(act).toHaveBeenCalledWith("openThread", root.id); +}); + +it("labels links as main does: its enabled link renderer, channel names from its list and its byline for message links, in rows and the editor", async () => { + const h = harness(); + const design = { + id: "design", + name: "design", + channelType: "stream" as const, + members: [h.session.viewer], + }; + const session = { + ...h.session, + channels: { + ...h.session.channels, + list: () => ({ ...h.session.channels.list(), channels: [design] }), + }, + }; + const owner = createHuddleComposerOwner( + session, + h.room, + "parent", + { + tools: { snapshot: () => [], subscribe: () => () => {} }, + links: { + snapshot: () => [ + { + ...linkRenderer, + key: "buzz.links/links", + pluginId: "buzz.links", + revision: "bundled", + }, + ], + subscribe: () => () => {}, + }, + }, + () => true, + ); + cleanup.push(() => owner.dispose()); + const client = createHuddleComposerClient(owner.token); + cleanup.push(() => client.dispose()); + await vi.waitFor(() => + expect(client.snapshot().data?.links).toEqual(["buzz.links/links"]), + ); + const message = `buzz://message?channel=design&id=${"9".repeat(64)}`; + const unknown = `buzz://message?channel=design&id=${"8".repeat(64)}`; + const mounted = render( + ), + text: `See ${message}, ${unknown} and buzz://channel/design`, + linkLabels: { [message]: "Bea in #design" }, + }, + }} + act={vi.fn()} + />, + ); + // Main's byline for the link it has read; the channel's name for one it + // hasn't, and for a channel link; each with the renderer's icon. + // The renderer arrives with the bridge's first snapshot. + await waitFor(() => + expect( + screen + .getByRole("link", { name: "Bea in #design" }) + .querySelector('[data-link-kind="message"]'), + ).toBeTruthy(), + ); + const pending = screen.getByRole("link", { name: "design" }); + expect(pending.getAttribute("href")).toBe(unknown); + expect(pending.querySelector('[data-link-kind="message"]')).toBeTruthy(); + const channel = screen.getByRole("link", { name: "#design" }); + expect(channel.querySelector('[data-link-kind="channel"]')).toBeTruthy(); + // The editor decorates a pasted message link with the same renderer. + const input = await screen.findByRole("textbox", { name: "Reply to thread" }); + act(() => { + input.focus(); + fireEvent.paste(input, { + clipboardData: { + items: [], + getData: (type: string) => (type === "text/plain" ? `${message} ` : ""), + }, + }); + }); + await waitFor(() => + expect( + mounted.container.querySelector('form [data-link-kind="message"]') + ?.textContent, + ).toBe("design"), + ); +}); diff --git a/src/bundled/popout/PopoutWindowView.test.tsx b/src/bundled/popout/PopoutWindowView.test.tsx new file mode 100644 index 0000000000..57dc3831f9 --- /dev/null +++ b/src/bundled/popout/PopoutWindowView.test.tsx @@ -0,0 +1,581 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { CompanionWorking } from "../../features/companion/CompanionWorking"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import type { PopoutView } from "../../features/popout/window"; +import { PopoutWindowView } from "./PopoutWindowView"; +import { popoutThreadRoot } from "."; + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(cleanup); + +const row = (id: string, text: string) => ({ + id, + author: "Ada", + authorId: "a".repeat(64), + channelId: "channel", + attachments: [], + picture: null, + text, + time: 1_700_000_000, + delivery: "seen" as const, +}); +const view = (patch: Partial = {}): PopoutView => ({ + title: "Design", + scope: "https://relay.test:viewer", + dark: false, + status: "ready", + writable: false, + canLoadMore: false, + limited: false, + loadingOlder: false, + reading: undefined, + readingRevision: 0, + operations: [], + following: false, + isDm: false, + kind: "channel", + thread: true, + version: 1, + root: row("1".repeat(64), "Root message"), + replies: [row("2".repeat(64), "First reply")], + ...patch, +}); + +it("names what it shows, as Slack's pop-out does, and reports its guard", () => { + const act = vi.fn(); + const { rerender } = render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Thread #Design", + ); + expect(screen.getByRole("img", { name: "Thread" })).toBeTruthy(); + rerender( + , + ); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Thread Ada", + ); + // A conversation frame shows the kind the tab strip would: the DM's + // person, the Solo bubble, a lock, or a hash — named for readers who can't + // see it. Only a hash conversation is written with `#`. + rerender( + , + ); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Ada"); + const dm = screen.getByRole("img", { name: "Direct message" }); + expect(dm.querySelector("img")?.getAttribute("src")).toBe( + "http://buzz-media.localhost/ada", + ); + rerender( + , + ); + expect(screen.getByRole("img", { name: "Solo chat" })).toBeTruthy(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(/^Plan$/); + // A plugin's icon for the chat, drawn from its description with its tone. + rerender( + , + ); + const pluginIcon = screen.getByRole("img", { + name: "Solo chat: Pull request, open, checks failing", + }); + expect(pluginIcon.getAttribute("data-tone")).toBe("failure"); + expect(pluginIcon.querySelector("svg")).toBeTruthy(); + // An icon name this host doesn't know — even a prototype key — draws the + // chat default rather than failing the window. + rerender( + , + ); + expect( + screen + .getByRole("img", { name: "Solo chat: Unknown" }) + .querySelector("svg"), + ).toBeTruthy(); + rerender( + , + ); + expect(screen.getByRole("img", { name: "Private channel" })).toBeTruthy(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + /^leads$/, + ); + rerender(); + expect(screen.getByRole("img", { name: "Public channel" })).toBeTruthy(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "#Design", + ); + rerender(); + expect(screen.getByText("Root message")).toBeTruthy(); + expect(screen.getByText("First reply")).toBeTruthy(); + // The guard is reported natively from the start: nothing is held yet. + expect(act).toHaveBeenCalledWith("guard", "0"); + expect(screen.getByText("This thread is read-only here.")).toBeTruthy(); +}); + +it("asks before a guarded close and confirms through the owner", () => { + const act = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole("alertdialog")).toBeNull(); + rerender(); + expect(screen.getByRole("alertdialog")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Close window" })); + expect(act).toHaveBeenCalledWith("close"); +}); + +it("goes read-only with a notice while the owner is away", () => { + render( + , + ); + expect(screen.getByRole("status").textContent).toMatch(/reconnecting/); + expect(screen.queryByText("This thread is read-only here.")).toBeNull(); +}); + +it("sends Buzz links to main and leaves web links to the OS opener", () => { + const act = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" })); + expect(act.mock.calls.filter((c) => c[0] === "openLink")).toEqual([]); + const buzz = screen + .getAllByRole("link") + .find((link) => link.getAttribute("href")?.startsWith("buzz:")); + if (buzz) { + fireEvent.click(buzz); + expect(act).toHaveBeenCalledWith( + "openLink", + expect.stringMatching(/^buzz:/), + ); + } +}); + +it("offers the action only for delivered messages that have a thread", () => { + const base = { + id: "3".repeat(64), + channelId: "c", + authorId: "a".repeat(64), + createdAt: 1, + content: "", + attachments: [], + mentions: [], + reactions: [], + participants: [], + }; + expect(popoutThreadRoot({ ...base, replyCount: 0 })).toBeUndefined(); + expect(popoutThreadRoot({ ...base, replyCount: 2 })).toBe(base.id); + expect( + popoutThreadRoot({ ...base, replyCount: 0, threadRootId: "r".repeat(64) }), + ).toBe("r".repeat(64)); + expect( + popoutThreadRoot({ ...base, replyCount: 2, delivery: "failed" }), + ).toBeUndefined(); +}); + +it("shows projected reactions even before the bridge connects", () => { + render( + , + ); + expect(screen.getByText("👍")).toBeTruthy(); +}); + +it("shows a conversation as a timeline with day dividers, drills into a row's thread in place and offers the way back", () => { + const act = vi.fn(); + const day = 86_400; + const conversation = view({ + thread: false, + root: undefined, + writable: true, + replies: [ + { ...row("1".repeat(64), "Monday"), time: 1_700_000_000 }, + { + ...row("2".repeat(64), "Tuesday, with a thread"), + time: 1_700_000_000 + day, + replyCount: 3, + }, + ], + }); + const { rerender } = render( + , + ); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "#Design", + ); + expect( + screen.queryByRole("button", { name: "Back to conversation" }), + ).toBeNull(); + expect(screen.getByRole("log", { name: "Messages" })).toBeTruthy(); + // One divider per day shown. + expect(document.querySelectorAll("[data-day]")).toHaveLength(2); + fireEvent.click( + screen.getByRole("button", { name: /View thread: 3 replies/ }), + ); + expect(act).toHaveBeenCalledWith("openThread", "2".repeat(64)); + rerender( + , + ); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Thread #Design", + ); + fireEvent.click(screen.getByRole("button", { name: "Back to conversation" })); + expect(act).toHaveBeenCalledWith("back"); +}); + +it("shows membership activity as one notice per run and says who is typing or working, in main's words", () => { + const act = vi.fn(); + const ada = "a".repeat(64); + const bob = "b".repeat(64); + const carol = "c".repeat(64); + const conversation = view({ + thread: false, + root: undefined, + replies: [ + row("1".repeat(64), "Hello"), + { + ...row("2".repeat(64), ""), + authorId: ada, + membership: { type: "member_joined", actor: carol, target: ada }, + mentionNames: { [ada]: "Ada", [carol]: "Carol" }, + }, + { + ...row("3".repeat(64), ""), + authorId: bob, + membership: { type: "member_joined", actor: carol, target: bob }, + mentionNames: { [bob]: "Bob", [carol]: "Carol" }, + }, + row("4".repeat(64), "Welcome"), + ], + typing: { humans: ["Ada", "Bob"], more: 1, agents: ["Echo"] }, + }); + render(); + expect(screen.getByText("Hello")).toBeTruthy(); + expect(screen.getByText("Welcome")).toBeTruthy(); + const notices = document.querySelectorAll("[data-membership-row]"); + expect(notices).toHaveLength(1); + expect(notices[0]?.textContent).toMatch(/Carol/); + expect(notices[0]?.textContent).toMatch(/Ada/); + expect(notices[0]?.textContent).toMatch(/Bob/); + expect( + document.querySelector("[data-conversation-activity]")?.textContent, + ).toBe("Echo is working. Ada, Bob and 1 others are typing"); +}); + +it("shows the member stack main's header shows and opens members in the main window", () => { + const act = vi.fn(); + const { rerender } = render( + , + ); + const members = screen.getByRole("button", { + name: "5 members of #Design; opens in the main window", + }); + expect( + members.querySelector('img[src="http://buzz-media.localhost/ada"]'), + ).toBeTruthy(); + expect(members).toHaveTextContent("+2"); + fireEvent.click(members); + expect(act).toHaveBeenCalledWith("openMembers"); + // The overflow is the whole count, however large. + rerender( + , + ); + expect( + screen.getByRole("button", { name: /^1001 members of #Design/ }), + ).toHaveTextContent("+998"); + // A Solo chat counts its agents; none known yet shows the agents glyph. + rerender( + , + ); + expect( + screen.getByRole("button", { + name: "0 agents in Plan; opens in the main window", + }), + ).toBeTruthy(); + expect(screen.queryByText("+2")).toBeNull(); +}); + +it("shows the agents at work as main's response preview does, with Stop, and names them for assistive technology", async () => { + const act = vi.fn(); + const working = [ + { + id: "3".repeat(64), + name: "Echo", + picture: null, + state: "working" as const, + step: "Reading src/app.ts", + status: "Reading src/app.ts", + activity: `buzz://profile/${"3".repeat(64)}/activity`, + stops: [{ key: "k1", label: "Stop Echo", lifecycle: true }], + }, + { + id: "4".repeat(64), + name: "Foxtrot", + picture: null, + state: "waiting" as const, + step: "", + status: "Received · waiting to start", + stops: [], + }, + ]; + render( + + + , + ); + // The preview groups every working agent; the status line keeps only the + // humans visibly and the whole sentence for assistive technology. + expect(screen.getByRole("img", { name: "Echo, Foxtrot" })).toBeTruthy(); + // The step shows in the preview and in Echo's row. + expect(screen.getAllByText("Reading src/app.ts").length).toBeGreaterThan(1); + const status = screen.getByRole("status", { + name: "Echo, Foxtrot are working. Ada is typing", + }); + expect(status).toHaveTextContent(/^Ada is typing$/); + // Activity opens in main; Stop asks main and reports a refusal here. + fireEvent.click(screen.getByRole("button", { name: "View Echo activity" })); + expect(act).toHaveBeenCalledWith( + "openLink", + `buzz://profile/${"3".repeat(64)}/activity`, + ); + // Stop is main's own state owner (`useWorkStops`): a refusal ends the + // attempt and is reported as a notice. + fireEvent.click(screen.getByRole("button", { name: "Stop Echo" })); + expect( + await screen.findByText( + "Couldn’t stop Echo. This window is no longer connected.", + ), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Stop Echo" })).toHaveTextContent( + "Stop", + ); + expect(screen.queryByRole("button", { name: /Stop Foxtrot/ })).toBeNull(); +}); + +it("keeps a hosted Stop pending only for main's retry window, then offers it again", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const stop = vi.fn(async () => ({ lifecycle: false, running: true })); + const working = [ + { + id: "3".repeat(64), + name: "Echo", + picture: null, + state: "working" as const, + step: "", + status: "Working", + stops: [{ key: "hosted", label: "Stop Echo", lifecycle: false }], + }, + ]; + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Stop Echo" })); + expect(await screen.findByText("Stopping Echo")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Stop Echo" })).toHaveTextContent( + "Stopping…", + ); + expect(stop).toHaveBeenCalledExactlyOnceWith("hosted"); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_001); + }); + expect( + await screen.findByText("Echo hasn’t stopped yet. Try again."), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Stop Echo" })).toHaveTextContent( + /^Stop$/, + ); + } finally { + vi.useRealTimers(); + } +}); + +it("shows a request row's live work under that row with Stop, as main's row bubble does", async () => { + const act = vi.fn(); + const live = [ + { + id: "3".repeat(64), + name: "Echo", + picture: null, + state: "working" as const, + step: "Running a command", + status: "Running a command", + stops: [{ key: "row-stop", label: "Stop Echo", lifecycle: true }], + }, + ]; + render( + + + , + ); + const request = screen.getByText("Please run it"); + const bubble = screen.getByRole("img", { name: "Echo" }); + // The bubble follows its request and precedes the next message. + expect( + request.compareDocumentPosition(bubble) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + bubble.compareDocumentPosition(screen.getByText("A reply")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getAllByText("Running a command").length).toBeGreaterThan(0); + // Stop without a connected owner reports its refusal, like the footer's. + fireEvent.click(screen.getByRole("button", { name: "Stop Echo" })); + expect( + await screen.findByText( + "Couldn’t stop Echo. This window is no longer connected.", + ), + ).toBeTruthy(); +}); diff --git a/src/bundled/popout/PopoutWindowView.tsx b/src/bundled/popout/PopoutWindowView.tsx new file mode 100644 index 0000000000..d9f67e7740 --- /dev/null +++ b/src/bundled/popout/PopoutWindowView.tsx @@ -0,0 +1,676 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { PopoutAction, PopoutView } from "../../features/popout/window"; +import { createReadingProxy } from "../../features/popout/reading-bridge"; +import { isBuzzLink } from "../../features/navigation/buzz-links"; +import { CompanionRows } from "../../features/companion/CompanionRows"; +import { CompanionWorking } from "../../features/companion/CompanionWorking"; +import { chatIconFor } from "../../features/conversation/chat-icon-catalog"; +import { createCompanionOperations } from "../../features/companion/operations"; +import { toMessage } from "../../features/companion/CompanionManagementItems"; +import type { CompanionRow } from "../../features/companion/row"; +import { ReportMessageDialog } from "../../features/messages/ReportMessageDialog"; +import { MessageEditScope } from "../../features/messages/MessageEditScope"; +import { Reading } from "../../features/messages/use-reading"; +import { useCompanionComposer } from "../huddles/HuddleComposer"; +import { huddleComposerExtensions } from "../huddles/composer-extensions"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { EmptyState } from "../../shared/design-system/ui/EmptyState"; +import { + CaretLeftIcon, + ChatCircleIcon, + HashIcon, + LockIcon, + MessageReplyIcon, + RobotIcon, + UsersIcon, +} from "../../shared/design-system/icons"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { AvatarStack } from "../../shared/design-system/ui/AvatarStack"; +import { SurfaceButton } from "../../shared/design-system/ui/SurfaceButton"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import styles from "./Popout.module.css"; + +const noSubscription = () => () => {}; +const zero = () => 0; +const none: readonly import("../../features/relay/outbox").OutgoingEvent[] = []; +const noOperations = () => none; + +/** The design-system artwork a plugin named for a Solo chat. */ +function PluginChatIcon({ name }: { name: string }) { + const Icon = chatIconFor(name); + return ; +} + +export function PopoutWindowView({ + view, + act, + error, +}: { + view: PopoutView; + act(action: PopoutAction, text?: string): void; + error?: string | undefined; +}) { + const log = useRef(null); + const tail = useRef(null); + const composerHost = useRef(null); + const following = useRef(true); + const settled = useRef(true); + const [held, setHeld] = useState(false); + const [confirming, setConfirming] = useState(0); + // A voice note being captured is lost when its editor unmounts, which + // changing frames does; navigation asks first while one is in progress. + const [capturing, setCapturing] = useState(false); + const [pendingNavigation, setPendingNavigation] = useState< + { action: "openThread"; rootId: string } | { action: "back" } + >(); + const navigate = ( + next: { action: "openThread"; rootId: string } | { action: "back" }, + ) => { + if (capturing) { + setPendingNavigation(next); + return; + } + if (next.action === "openThread") act("openThread", next.rootId); + else act("back"); + }; + const rows = useMemo( + () => (view.root ? [view.root, ...view.replies] : view.replies), + [view.root, view.replies], + ); + // Named as the tab strip names it: `#` only where the icon is a hash. + const conversation = view.kind === "channel" ? `#${view.title}` : view.title; + const kindLabel = { + channel: "Public channel", + private: "Private channel", + dm: "Direct message", + solo: "Solo chat", + }[view.kind]; + // Main's header identity: the member stack opens the access dialog there. + const members = view.members; + const membersLabel = members + ? members.solo + ? `${members.count} ${members.count === 1 ? "agent" : "agents"} in ${view.title}; opens in the main window` + : `${members.count} ${members.count === 1 ? "member" : "members"} of ${conversation}; opens in the main window` + : undefined; + // Main's own words for who is working and typing here. Agents whose work + // the preview below shows are named only for assistive technology. + const working = view.typing?.working ?? []; + const workingText = view.typing?.agents.length + ? `${view.typing.agents.join(", ")}${view.typing.agents.length === 1 ? " is working" : " are working"}` + : ""; + const humansText = view.typing?.humans.length + ? `${view.typing.humans.join(", ")}${view.typing.more ? ` and ${view.typing.more} others` : ""}${view.typing.humans.length + view.typing.more === 1 ? " is typing" : " are typing"}` + : ""; + const typingText = [workingText, humansText].filter(Boolean).join(". "); + // What the status line shows: the preview already shows the agents. + const shownTypingText = working.length ? humansText : typingText; + // Companion wording follows the frame: a thread's replies or a + // conversation's messages. + const what = view.thread ? "thread" : "conversation"; + const newest = rows.at(-1)?.id; + useEffect(() => { + if (newest && following.current) + tail.current?.scrollIntoView({ block: "end" }); + }, [newest]); + // Guard state is native-owned until the window is destroyed: the OS close + // request asks here first while work would be lost. + useEffect(() => { + act("guard", held ? "1" : "0"); + }, [act, held]); + const closeRequested = view.closeRequested ?? 0; + useEffect(() => { + if (closeRequested) setConfirming(closeRequested); + }, [closeRequested]); + const onHeld = useCallback((value: boolean) => setHeld(value), []); + const [operationsSession, setOperationsSession] = + useState>(); + // Editable rows carry main's exact baseline for the shared edit flow. + const editMessages = useMemo( + () => rows.filter((row) => row.editable).map(toMessage), + [rows], + ); + // Reply to one message within the thread; cleared when it leaves the view. + const [replyParent, setReplyParent] = useState(); + const replyTarget = rows.find((row) => row.id === replyParent); + const replyContext = replyTarget ? ( +
+ Replying to {replyTarget.author} + +
+ ) : undefined; + const { composer, client } = useCompanionComposer( + view.detached ? undefined : view.composer, + { + channelName: view.title, + label: view.thread ? "Reply to thread" : `Message ${conversation}`, + placeholder: view.thread ? "Reply to thread" : `Message ${conversation}`, + onHeld, + replyParentId: replyTarget?.id, + replyContext, + editMessages, + // The operations session is built from the client below; one render + // after it exists the editor adopts it (edits then follow one id). + session: operationsSession, + // Voice notes record here; main uploads them like any attachment. + capture: true, + onCapturing: setCapturing, + }, + ); + // Reactions and the picker use the same bridge as the composer: one owner + // in main admits them against the rows this window shows. + const rowsRef = useRef(rows); + rowsRef.current = rows; + useSyncExternalStore( + client?.subscribe ?? noSubscription, + client?.revision ?? zero, + ); + const bridgeSession = client?.snapshot().session; + const toolKeys = JSON.stringify([ + client?.snapshot().data?.tools ?? [], + client?.snapshot().data?.links ?? [], + ]); + const reactions = useMemo(() => { + if (!client || !bridgeSession) return undefined; + const session = createCompanionOperations(bridgeSession, client, { + messageOf: (eventId) => + rowsRef.current.find((row) => + row.reactions?.some((group) => + group.events.some((event) => event.id === eventId), + ), + )?.id, + sourceOf: (messageId) => { + const row = rowsRef.current.find((row) => row.id === messageId); + return row?.editable?.sourceContent ?? row?.text; + }, + }); + const [tools, links] = JSON.parse(toolKeys) as [string[], string[]]; + const extensions = huddleComposerExtensions({ + tools, + completions: [], + links, + gifRequests: client.gifs, + }); + return { + session, + tools: extensions.tools, + inline: extensions.inline, + links: extensions.links, + }; + }, [client, bridgeSession, toolKeys]); + useEffect(() => { + setOperationsSession(reactions?.session); + return () => reactions?.session.dispose(); + }, [reactions]); + // Main's own operations ride the view: delivery, failure, retry. + const mainOperations = view.operations; + const mainVersion = view.version; + useEffect(() => { + reactions?.session.setMainOperations(mainOperations, mainVersion); + }, [reactions, mainOperations, mainVersion]); + const operations = useSyncExternalStore( + reactions?.session.outbox?.subscribe ?? noSubscription, + reactions?.session.outbox?.snapshot ?? noOperations, + ); + const [deleting, setDeleting] = useState<{ + row: CompanionRow; + operationId?: string; + }>(); + const [reporting, setReporting] = useState(); + const [managementError, setManagementError] = useState(); + const ask = useCallback((work: () => Promise) => { + setManagementError(undefined); + work().catch((cause: unknown) => + setManagementError( + cause instanceof Error ? cause.message : "Couldn’t complete that.", + ), + ); + }, []); + const stopWork = useCallback( + (key: string) => + client + ? client.stop({ key }) + : Promise.reject(new Error("This window is no longer connected.")), + [client], + ); + const management = useMemo(() => { + if (!client || !reactions) return undefined; + const session = reactions.session; + return { + following: view.following, + isDm: view.isDm, + busy: (messageId: string) => + operations.some( + (item) => + ["sending", "failed", "unknown"].includes(item.delivery) && + session.messages.reactionTarget(item.event) === messageId && + (item.event.kind === 5 || item.event.kind === 40003), + ), + deleteMessage: (row: CompanionRow) => setDeleting({ row }), + sendToChannel: (row: CompanionRow) => { + try { + session.messages.sendToChannel(row.id); + } catch (cause) { + setManagementError( + cause instanceof Error ? cause.message : "Couldn’t send.", + ); + } + }, + markRead: (row: CompanionRow, unread: boolean) => + ask(() => client.markRead({ messageId: row.id, unread })), + follow: (following: boolean) => ask(() => client.follow({ following })), + report: (row: CompanionRow) => setReporting(row), + // Main admits the handoff, then this window brings main forward so + // the action opens where the user is looking. + handoff: (row: CompanionRow, action: { key: string; revision: string }) => + ask(async () => { + await client.handoff({ + messageId: row.id, + key: action.key, + revision: action.revision, + }); + act("focusMain"); + }), + }; + }, [client, reactions, view.following, view.isDm, operations, ask, act]); + const deleteOperation = deleting?.operationId + ? operations.find((item) => item.event.id === deleting.operationId) + : undefined; + useEffect(() => { + // Delivered (or gone from main after admission): the dialog's work is done. + if ( + deleting?.operationId && + (!deleteOperation || + deleteOperation.delivery === "accepted" || + deleteOperation.delivery === "seen") + ) + setDeleting(undefined); + }, [deleting, deleteOperation]); + + const focusConversation = () => { + const editor = composerHost.current?.querySelector( + '[role="textbox"]:not([aria-disabled="true"]), textarea:not(:disabled)', + ); + (editor ?? log.current)?.focus({ preventScroll: true }); + }; + const readingToken = view.detached ? undefined : view.reading; + const readingRevision = useRef(view.readingRevision); + readingRevision.current = view.readingRevision; + const [readingSession, setReadingSession] = + useState>(); + useEffect(() => { + if (!readingToken) { + setReadingSession(undefined); + return; + } + const proxy = createReadingProxy( + readingToken, + () => true, + () => readingRevision.current, + ); + setReadingSession(proxy); + return () => proxy.dispose(); + }, [readingToken]); + return ( + +
+ {/* As Slack's pop-out: what this window is, and of which conversation; + a frame opened from the conversation offers the way back to it. */} +
+ {view.canGoBack && ( + } + onClick={() => navigate({ action: "back" })} + /> + )} + {/* The same icon the main window's tab shows for this conversation + (a thread frame shows the thread mark), so the kind is read at a + glance where the name alone does not say it (DM, Solo). */} + + {view.thread ? ( + + ) : view.kind === "solo" && view.icon ? ( + // A plugin's icon for this chat, drawn from its description. + + ) : view.kind === "dm" ? ( + + ) : view.kind === "private" ? ( + + ) : view.kind === "solo" ? ( + + ) : ( + + )} + +

+ {view.thread ? ( + <> + Thread{" "} + {conversation} + + ) : ( + {conversation} + )} +

+ {members && ( + act("openMembers")} + > + {members.preview.length ? ( + ({ + id: person.id, + name: person.name, + src: person.picture ?? undefined, + shape: members.solo + ? ("squircle" as const) + : ("circle" as const), + }))} + /> + ) : ( + + )} +
+ {view.detached && ( +

+ Buzz is reconnecting. This window shows what it last received. +

+ )} + {error && ( +

+ {error} +

+ )} +
{ + const el = e.currentTarget; + following.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 48; + }} + > + {readingSession && rows[0] && ( + + )} + {view.limited && ( +

+ Showing the latest 500 {view.thread ? "replies" : "messages"}. +

+ )} + {view.canLoadMore && ( + + )} + {!rows.length && + (view.status === "loading" ? ( +

+ Loading {what}… +

+ ) : view.status === "ready" && !view.error ? ( + } title="No messages yet" /> + ) : null)} + { + setReplyParent(messageId); + focusConversation(); + }, + management, + stopWork, + // A conversation drills into one of its threads here, in this + // window; main re-points the projection and keeps the way back. + onOpenThread: view.thread + ? undefined + : (rootId) => navigate({ action: "openThread", rootId }), + }} + layout={view.thread ? "thread" : "timeline"} + openLink={(url) => { + // Buzz targets continue in main; the OS browser takes the rest. + if (!isBuzzLink(url)) return false; + act("openLink", url); + return true; + }} + recover={(id, dismiss) => + act(dismiss ? "discardMessage" : "retryMessage", id) + } + focusConversation={focusConversation} + /> + {working.length > 0 && ( + { + if (!isBuzzLink(target)) return false; + act("openLink", target); + return true; + }} + /> + )} + {typingText && ( +

+ {shownTypingText || typingText} +

+ )} +
+
+ {view.error && ( +
+ {view.error}{" "} + +
+ )} + {view.writable && !view.detached ? ( +
+ {composer} +
+ ) : ( + !view.detached && ( +

+ {view.status === "ready" && !view.error + ? `This ${what} is read-only here.` + : `Connecting to this ${what}…`} +

+ ) + )} + {managementError && ( +

+ {managementError} +

+ )} + {deleting && reactions && ( + setDeleting(undefined)} + actions={ + <> + + + + } + > + {deleteOperation?.error && ( +

+ {deleteOperation.error} +

+ )} +
+ )} + {reporting && client && ( + + client.report({ messageId: reporting.id, type, note: note ?? "" }) + } + close={() => setReporting(undefined)} + /> + )} + {pendingNavigation && ( + setPendingNavigation(undefined)} + actions={ + <> + + + + } + /> + )} + {confirming > 0 && ( + setConfirming(0)} + actions={ + <> + + + + } + /> + )} +
+ + ); +} diff --git a/src/bundled/popout/README.md b/src/bundled/popout/README.md new file mode 100644 index 0000000000..5cb45ab3c5 --- /dev/null +++ b/src/bundled/popout/README.md @@ -0,0 +1,9 @@ +# Pop-out windows + +Keep a thread in view, and answer it, while the main Buzz window moves on. + +## Getting started + +1. Open a message's actions menu (the `⋯` on a message that has a thread) and choose **Open thread in new window**. +2. Read and reply in the new window. Your draft is the same one the thread shows in the main window. +3. Choose **Open in main window** to bring the thread back into the main window; the pop-out stays open until you close it. diff --git a/src/bundled/popout/index.test.ts b/src/bundled/popout/index.test.ts new file mode 100644 index 0000000000..31f73f69a6 --- /dev/null +++ b/src/bundled/popout/index.test.ts @@ -0,0 +1,187 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { messageActionRequests } from "../../features/messages/action-requests"; +import { soloAccessRequests } from "../../features/solo/access-requests"; +import { apply } from "./index"; + +const h = vi.hoisted(() => ({ + options: undefined as + | undefined + | { + handoff(target: unknown, messageId: string, action: unknown): void; + openActivity(activityTarget: string, target: unknown): void; + openMembers(channelId: string, solo: boolean): void; + }, +})); +vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => true })); +vi.mock("../../features/popout/window", () => ({ + createPopoutWindows: (_relay: unknown, options: typeof h.options) => { + h.options = options; + return { + notices: { snapshot: () => [], subscribe: () => () => {} }, + held: () => false, + subscribeHeld: () => () => {}, + dispose: async () => {}, + }; + }, +})); +afterEach(() => { + messageActionRequests.clear(); + soloAccessRequests.clear(); +}); + +const target = { channelId: "channel", threadRootId: "b".repeat(64) }; +const activity = `buzz:profile?agent=${"c".repeat(64)}&tab=activity`; +const action = { key: "plugin:remind", revision: "1", id: "remind" }; + +function setup() { + let connection = { + viewer: "a".repeat(64), + origin: "https://relay.test", + scope: "https://relay.test:viewer", + session: {}, + }; + const open = vi.fn(); + const requestOpen = vi.fn(); + apply({ + relay: { snapshot: () => connection }, + conversation: { + actions: {}, + registerAccessory: vi.fn(), + registerMessageAction: vi.fn(), + registerTabAction: vi.fn(), + }, + navigation: { open }, + panels: { requestOpen }, + communityReader: {}, + effect: vi.fn(), + } as never); + return { + open, + requestOpen, + switchCommunity: () => { + connection = { ...connection, scope: "https://other.test:viewer" }; + }, + replaceSession: () => { + connection = { ...connection, session: {} }; + }, + }; +} + +it("continues into the activity panel only after the navigation opened in the same connection", async () => { + let x = setup(); + x.open.mockResolvedValue({ status: "opened" }); + h.options?.openActivity(activity, target); + await Promise.resolve(); + expect(x.requestOpen).toHaveBeenCalledWith({ + target: activity, + channelId: target.channelId, + rootId: target.threadRootId, + }); + + x = setup(); + x.open.mockResolvedValue({ status: "failed", reason: "not-found" }); + h.options?.openActivity(activity, target); + await Promise.resolve(); + expect(x.requestOpen).not.toHaveBeenCalled(); + + x = setup(); + let settle!: (value: unknown) => void; + x.open.mockReturnValue(new Promise((resolve) => (settle = resolve))); + h.options?.openActivity(activity, target); + x.switchCommunity(); + settle({ status: "opened" }); + await Promise.resolve(); + expect(x.requestOpen).not.toHaveBeenCalled(); + + x = setup(); + x.open.mockReturnValue(new Promise((resolve) => (settle = resolve))); + h.options?.openActivity(activity, target); + x.replaceSession(); + settle({ status: "opened" }); + await Promise.resolve(); + expect(x.requestOpen).not.toHaveBeenCalled(); +}); + +it("requests the handed-off action for the row only after main opened the message in the same connection", async () => { + const messageId = "d".repeat(64); + let x = setup(); + x.open.mockResolvedValue({ status: "opened" }); + h.options?.handoff(target, messageId, action); + await Promise.resolve(); + expect(messageActionRequests.snapshot()).toEqual({ + session: expect.anything(), + scope: "https://relay.test:viewer", + channelId: target.channelId, + messageId, + key: action.key, + revision: action.revision, + }); + messageActionRequests.clear(); + + x = setup(); + x.open.mockResolvedValue({ status: "cancelled" }); + h.options?.handoff(target, messageId, action); + await Promise.resolve(); + expect(messageActionRequests.snapshot()).toBeUndefined(); + + x = setup(); + let settle!: (value: unknown) => void; + x.open.mockReturnValue(new Promise((resolve) => (settle = resolve))); + h.options?.handoff(target, messageId, action); + x.switchCommunity(); + settle({ status: "opened" }); + await Promise.resolve(); + expect(messageActionRequests.snapshot()).toBeUndefined(); + + x = setup(); + x.open.mockReturnValue(new Promise((resolve) => (settle = resolve))); + h.options?.handoff(target, messageId, action); + x.replaceSession(); + settle({ status: "opened" }); + await Promise.resolve(); + expect(messageActionRequests.snapshot()).toBeUndefined(); +}); + +it("opens members as main's header does: the Members panel for a channel, and a Solo chat's Share access through its header after main shows the chat", async () => { + let x = setup(); + x.open.mockResolvedValue({ status: "opened" }); + h.options?.openMembers("channel", false); + expect(x.open).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "conversation", + channelId: "channel", + panel: "members", + }), + ); + expect(soloAccessRequests.snapshot()).toBeUndefined(); + + x = setup(); + x.open.mockResolvedValue({ status: "opened" }); + h.options?.openMembers("solo", true); + expect(x.open).toHaveBeenCalledWith( + expect.not.objectContaining({ panel: "members" }), + ); + await Promise.resolve(); + expect(soloAccessRequests.snapshot()).toEqual({ + session: expect.anything(), + scope: "https://relay.test:viewer", + channelId: "solo", + }); + soloAccessRequests.clear(); + + // A navigation that did not open, or one whose session was replaced + // meanwhile, hands nothing to the header. + x = setup(); + x.open.mockResolvedValue({ status: "cancelled" }); + h.options?.openMembers("solo", true); + await Promise.resolve(); + expect(soloAccessRequests.snapshot()).toBeUndefined(); + x = setup(); + let settle!: (value: unknown) => void; + x.open.mockReturnValue(new Promise((resolve) => (settle = resolve))); + h.options?.openMembers("solo", true); + x.replaceSession(); + settle({ status: "opened" }); + await Promise.resolve(); + expect(soloAccessRequests.snapshot()).toBeUndefined(); +}); diff --git a/src/bundled/popout/index.tsx b/src/bundled/popout/index.tsx new file mode 100644 index 0000000000..b2e417fb53 --- /dev/null +++ b/src/bundled/popout/index.tsx @@ -0,0 +1,222 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { useEffect, useSyncExternalStore } from "react"; +import { buzzLinkTarget } from "../../features/navigation/buzz-links"; +import { conversationTarget } from "../../features/channel-navigation/routes"; +import type { PluginModule } from "../../plugins/api"; +import { + createPopoutWindows, + type PopoutWindows, +} from "../../features/popout/window"; +import type { ChannelMessage } from "../../features/relay/contracts"; +import { messageActionRequests } from "../../features/messages/action-requests"; +import { soloAccessRequests } from "../../features/solo/access-requests"; +import { ArrowSquareOutIcon } from "../../shared/design-system/icons"; +import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; +import { useToastNotification } from "../../shared/design-system/ui/Toast"; + +export const inject = [ + "relay", + "conversation", + "navigation", + "agents", + "communityReader", + "panels", +]; + +/** The thread a message belongs to, when it has one to pop out. */ +export function popoutThreadRoot(message: ChannelMessage) { + if (message.delivery && !["accepted", "seen"].includes(message.delivery)) + return undefined; + if (message.threadRootId) return message.threadRootId; + return message.replyCount > 0 ? message.id : undefined; +} + +/** Owner notices reach the host toast stack once, whichever composer mounts first. */ +function PopoutNotices({ windows }: { windows: PopoutWindows }) { + const notify = useToastNotification(); + const notices = useSyncExternalStore( + windows.notices.subscribe, + windows.notices.snapshot, + ); + useEffect(() => { + for (const notice of notices) + if (windows.notices.claim(notice.id)) notify(notice.text, "info"); + }, [notices, windows, notify]); + return null; +} + +export const apply: PluginModule["apply"] = (ctx) => { + if (!isTauri()) return; + const openLink = (url: string) => { + const connection = ctx.relay.snapshot(); + if (!connection.viewer || !connection.origin) return false; + const target = buzzLinkTarget(url, { + viewer: connection.viewer, + communityOrigin: connection.origin, + }); + if (!target) return false; + void ctx.navigation.open(target); + return true; + }; + const messageTarget = ( + target: { channelId: string; threadRootId?: string | undefined }, + messageId: string | undefined, + ) => { + const connection = ctx.relay.snapshot(); + if (!connection.viewer || !connection.origin) return null; + const channel = encodeURIComponent(target.channelId); + // A conversation frame points at the channel; a thread frame at the + // message within its thread. + return buzzLinkTarget( + messageId + ? `buzz://message?channel=${channel}&id=${messageId}${target.threadRootId ? `&thread=${target.threadRootId}` : ""}` + : `buzz://channel/${channel}`, + { viewer: connection.viewer, communityOrigin: connection.origin }, + ); + }; + const windows = createPopoutWindows(ctx.relay, { + extensions: ctx.conversation, + openLink, + agents: ctx.agents, + actions: ctx.conversation.actions, + chatIcons: ctx.conversation.chatIcons, + // Handoff: main shows the message, then that row opens the exact action + // instance (a dialog action opens; a menu action's menu opens with it). + // The companion never runs a plugin action itself. + handoff: (target, messageId, action) => { + const open = messageTarget(target, messageId); + const { scope, session } = ctx.relay.snapshot(); + if (!open || !scope) return; + void ctx.navigation.open(open).then((result) => { + // The same session, not just the same scope: a replaced session is + // another conversation and the handoff is dropped. + if (result.status !== "opened") return; + const now = ctx.relay.snapshot(); + if (now.scope !== scope || now.session !== session) return; + messageActionRequests.request({ + session, + scope, + channelId: target.channelId, + messageId, + key: action.key, + revision: action.revision, + }); + }); + }, + // Members open as main's header opens them: a channel or DM with its + // Members panel (a route); a Solo chat's Share access dialog lives in its + // header, so main shows the chat, then that header takes the request — + // only from the same session the handoff was made in. + openMembers: (channelId, solo) => { + const { scope, session } = ctx.relay.snapshot(); + if (!scope) return; + let target: ReturnType; + try { + target = conversationTarget(scope, channelId); + } catch { + return; // An invalid scope opens nothing. + } + if (!solo) { + void ctx.navigation.open( + target.kind === "conversation" + ? { ...target, panel: "members" } + : target, + ); + return; + } + void ctx.navigation.open(target).then((result) => { + if (result.status !== "opened") return; + const now = ctx.relay.snapshot(); + if (now.scope !== scope || now.session !== session) return; + soloAccessRequests.request({ session, scope, channelId }); + }); + }, + // The agent's activity view opens beside the conversation once main + // shows it, through the existing panel opener with this channel context. + openActivity: (activityTarget, target) => { + const open = messageTarget(target, target.threadRootId); + const { scope, session } = ctx.relay.snapshot(); + if (!open || !scope) return; + void ctx.navigation.open(open).then((result) => { + // Only a navigation that opened, from the same session it started + // in, continues into the panel; a failure or a replacement does not. + if (result.status !== "opened") return; + const now = ctx.relay.snapshot(); + if (now.scope !== scope || now.session !== session) return; + ctx.panels.requestOpen({ + target: activityTarget, + channelId: target.channelId, + ...(target.threadRootId ? { rootId: target.threadRootId } : {}), + }); + }); + }, + }); + ctx.conversation.registerAccessory({ + id: "popout-notices", + title: "Pop-out window notices", + component: () => , + }); + ctx.conversation.registerMessageAction({ + id: "open-thread-window", + title: "Open thread in new window", + placement: "menu", + icon: () =>