From 8d1e7b01733e763cfb2f365a4dd60dd929412de3 Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:24:12 -0500 Subject: [PATCH 01/80] docs: pop-out conversation windows design proposal Proposal only; no behavior change. Compares Slack's Open in new window, records Buzz's Huddle companion precedent and main-only native authority, and recommends a sliced presentation-companion approach. Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- docs/popout-windows-design.md | 331 ++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/popout-windows-design.md diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md new file mode 100644 index 0000000000..b1ba5a7716 --- /dev/null +++ b/docs/popout-windows-design.md @@ -0,0 +1,331 @@ +# Pop-out conversation windows — design proposal + +Status: **proposal, not implemented.** Worktree `design/popout-window` at +`origin/main` 92d4c36. Authored by Jaw-fable51-pi for jaw; adversarial review by +jaw-gpt56sol requested before any implementation checkpoint. + +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 in new window** (shipped 2023). From product knowledge; +the Slack Help Center could not be fetched from this environment (bot challenge), +so items marked (?) should be confirmed in a Slack client before being treated +as parity targets. + +| Aspect | Slack behavior | +| --- | --- | +| Targets | Channels, DMs/group DMs, threads, canvases, huddles | +| Entry points | Right-click a sidebar conversation → *Open in new window*; conversation-name header menu → *Open in new window*; thread pane `⋯` → *Open in new window* | +| Window contents | Conversation header (name, basic actions), message list, full composer. No sidebar, no global search/nav | +| Count | Many pop-outs at once; macOS *Window* menu lists them | +| Same target twice | Reopening focuses the existing pop-out rather than making a second (?) | +| Main + pop-out both showing the same conversation | Allowed; both live, both update | +| Drafts | Shared — Slack drafts are server-synced, so main and pop-out show the same draft | +| Read state | One account state; reading in the pop-out clears unread in main | +| Links clicked inside a pop-out | Other conversations open in the **main** window (?) | +| Closing main | App keeps running (tray/dock); pop-outs keep working | +| Persistence | Pop-outs are not restored after app restart (?) | +| Keyboard | No dedicated shortcut; menu-driven | + +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 | +| 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 tab closes (see §5.1) | +| 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 (last writer wins; the other composer refreshes on + `storage` — already the contract). Attachment drafts stay with the window that + added them (as Huddle: "closing the panel releases its local files"). +3. Reading in the pop-out counts as a visit for unread, exactly as a Channels + tab does (one read-state owner: main). +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* closes the pop-out and opens the target as a main tab, + keeping the draft. +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 uses `replaceView` with revision comparison and `subscribeView` listens +to other windows' writes. Policy: last writer wins; a composer that receives a +`storage` change while **not focused** reloads the draft; a focused composer +keeps its in-progress text and the next keystroke writes it (today's single-window +behavior unchanged). Send clears the persisted draft from main +(Huddle: "An admitted message clears its persisted draft in the main app even if +the companion closes before acknowledgment"). To keep the first slice simple, +**popping a tab out closes that tab in main** (Slack does not do this; it avoids +the two-live-composers case most of the time while still allowing the user to +reopen the conversation in main). Reopen-in-main is one click. + +### 5.2 Which window owns signing / session actions + +**Main, only.** Pop-outs get a capability modeled on `huddle.json`: `watch`, +`action`, `media-download` (restricted to sources in its current projection, as +`media_download` already does for `huddle`), `clipboard-write-text`, +`opener:allow-open-url`. No `relay-*`, `identity-*`, `sign-*`. Every action is +re-validated in main against current membership/writability before it reaches +the outbox, as the composer owner does today. + +### 5.3 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, close pop-outs first (drafts are already persisted on + each change). Alternative (hide main on close, as macOS) is a separate + product decision; listed in §8. +- Renderer reload/crash of main: native holds the last validated projection per + pop-out (Huddle `watch` re-sends the cached presentation); on main reload the + renderer re-registers owners for live pop-outs or retires them with an + in-window "Reopen from Buzz" notice (Huddle's `reset_incoming` pattern). + +### 5.4 Community or scope changes + +Projection carries `scope`. Switching community, sign-out, relay-session +replacement, membership loss, channel archive → main retires the pop-outs of the +affected scope (`update(id, null)` → native `destroy()`), with one toast in main +naming what closed. Identical to the Huddle rule set. + +### 5.5 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 a `HashMap`; capability `popout.json` with glob label. Renderer: `popout.html` entry; `ConversationView` = Huddle `Discussion` + `scope`, `title`, `target {channelId, threadRootId}`; reuse `HuddleDiscussionView`/`HuddleComposer` moved to a shared owner. Actions: send/older/retry/discard/openLink/openInMain/closed. Focus-existing on reopen. Lifecycle rules §5.3–5.4. | ~900–1,300 lines (native 300–400, projection generalization 300–500, UI 200–300, menu items 50–100) | +| **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.6 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.5), owners reused (`huddle_window.rs`, + `composer-owner/client`, `discussion.ts`, `HuddleDiscussionView`, + `ContextMenu`/`MenuItem`, `PageTabs` tabMenu, `ThreadPanel` header), expected + files, diff budget (§5.5). +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 + +- **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. Popping a tab out: close the main tab (proposed) or keep both (Slack)? +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? +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. From 0282af721eaa4141d96f9e7c5f910b62c0980489 Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:29:37 -0500 Subject: [PATCH 02/80] docs: pop-out design revision 2 after adversarial review Adopt the composer's existing draft-conflict contract, make Open in main a focus (not a move), add per-window binding/incarnation, a real thread read projection, reading-lease based unread, and cite Slack's official help. Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- docs/popout-windows-design.md | 205 ++++++++++++++++++++++++---------- 1 file changed, 145 insertions(+), 60 deletions(-) diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md index b1ba5a7716..352fd77960 100644 --- a/docs/popout-windows-design.md +++ b/docs/popout-windows-design.md @@ -1,8 +1,9 @@ # Pop-out conversation windows — design proposal Status: **proposal, not implemented.** Worktree `design/popout-window` at -`origin/main` 92d4c36. Authored by Jaw-fable51-pi for jaw; adversarial review by -jaw-gpt56sol requested before any implementation checkpoint. +`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 @@ -13,25 +14,25 @@ this needs a desktop (Tauri) change. ## 1. The Slack reference -Slack desktop's **Open in new window** (shipped 2023). From product knowledge; -the Slack Help Center could not be fetched from this environment (bot challenge), -so items marked (?) should be confirmed in a Slack client before being treated -as parity targets. +Slack desktop's **Open separate windows** — official help: + +(fetched 2026-11-03). Documented facts: -| Aspect | Slack behavior | +| Aspect | Slack behavior (documented) | | --- | --- | -| Targets | Channels, DMs/group DMs, threads, canvases, huddles | -| Entry points | Right-click a sidebar conversation → *Open in new window*; conversation-name header menu → *Open in new window*; thread pane `⋯` → *Open in new window* | -| Window contents | Conversation header (name, basic actions), message list, full composer. No sidebar, no global search/nav | -| Count | Many pop-outs at once; macOS *Window* menu lists them | -| Same target twice | Reopening focuses the existing pop-out rather than making a second (?) | -| Main + pop-out both showing the same conversation | Allowed; both live, both update | -| Drafts | Shared — Slack drafts are server-synced, so main and pop-out show the same draft | -| Read state | One account state; reading in the pop-out clears unread in main | -| Links clicked inside a pop-out | Other conversations open in the **main** window (?) | -| Closing main | App keeps running (tray/dock); pop-outs keep working | -| Persistence | Pop-outs are not restored after app restart (?) | -| Keyboard | No dedicated shortcut; menu-driven | +| 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 @@ -98,9 +99,9 @@ Agents, Workflows), not Settings, not Canvas (separate request if wanted). | Surface | Item | Notes | | --- | --- | --- | -| Sidebar row right-click / `…` menu (channel, DM, Solo chat) | **Open in new window** | Same menu that holds Copy link / Mark read | +| 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 tab closes (see §5.1) | +| 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 | @@ -122,16 +123,16 @@ sidebar, no page nav, no search, no Settings. 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 (last writer wins; the other composer refreshes on - `storage` — already the contract). Attachment drafts stay with the window that - added them (as Huddle: "closing the panel releases its local files"). -3. Reading in the pop-out counts as a visit for unread, exactly as a Channels - tab does (one read-state owner: main). + 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* closes the pop-out and opens the target as a main tab, - keeping the draft. +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, @@ -207,27 +208,61 @@ Explicitly record the fidelity gap and the trigger for moving to C. ### 5.1 Same conversation open twice; who owns the draft One persisted draft per target, owned by **storage**, not a window. The composer -already uses `replaceView` with revision comparison and `subscribeView` listens -to other windows' writes. Policy: last writer wins; a composer that receives a -`storage` change while **not focused** reloads the draft; a focused composer -keeps its in-progress text and the next keystroke writes it (today's single-window -behavior unchanged). Send clears the persisted draft from main -(Huddle: "An admitted message clears its persisted draft in the main app even if -the companion closes before acknowledgment"). To keep the first slice simple, -**popping a tab out closes that tab in main** (Slack does not do this; it avoids -the two-live-composers case most of the time while still allowing the user to -reopen the conversation in main). Reopen-in-main is one click. - -### 5.2 Which window owns signing / session actions +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, retirement by main) runs the same guards as closing a tab: pending +attachment drafts or an unresolved conflict → confirm dialog naming what would be +lost. Retirement forced by scope change (§5.8) closes without prompting but +posts the loss in the main toast. 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 its current projection, as -`media_download` already does for `huddle`), `clipboard-write-text`, -`opener:allow-open-url`. No `relay-*`, `identity-*`, `sign-*`. Every action is -re-validated in main against current membership/writability before it reaches -the outbox, as the composer owner does today. - -### 5.3 Main window closes +`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). @@ -235,30 +270,62 @@ the outbox, as the composer owner does today. main with pop-outs open, close pop-outs first (drafts are already persisted on each change). Alternative (hide main on close, as macOS) is a separate product decision; listed in §8. -- Renderer reload/crash of main: native holds the last validated projection per - pop-out (Huddle `watch` re-sends the cached presentation); on main reload the - renderer re-registers owners for live pop-outs or retires them with an - in-window "Reopen from Buzz" notice (Huddle's `reset_incoming` pattern). - -### 5.4 Community or scope changes +- 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. Tests as listed. If this pushes S1 over budget +it ships as **S1b** before S2, because "don't lose the message" needs unread to +be truthful. + +### 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`. Switching community, sign-out, relay-session replacement, membership loss, channel archive → main retires the pop-outs of the affected scope (`update(id, null)` → native `destroy()`), with one toast in main naming what closed. Identical to the Huddle rule set. -### 5.5 Initial slice vs full parity +### 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 a `HashMap`; capability `popout.json` with glob label. Renderer: `popout.html` entry; `ConversationView` = Huddle `Discussion` + `scope`, `title`, `target {channelId, threadRootId}`; reuse `HuddleDiscussionView`/`HuddleComposer` moved to a shared owner. Actions: send/older/retry/discard/openLink/openInMain/closed. Focus-existing on reopen. Lifecycle rules §5.3–5.4. | ~900–1,300 lines (native 300–400, projection generalization 300–500, UI 200–300, menu items 50–100) | +| **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.6 Validation +### 5.10 Validation - Native: wire encoding/validation tests beside the Huddle ones (`huddle_window.rs` tests); label-gating tests for the new capability. @@ -276,10 +343,10 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. 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.5), owners reused (`huddle_window.rs`, + (§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.5). + 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`); @@ -306,6 +373,10 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. ## 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. @@ -321,11 +392,25 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. 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. Popping a tab out: close the main tab (proposed) or keep both (Slack)? +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? +4. Is S1 = threads only acceptable as the first PR, with channels/DMs/Solo in + S2? And is an S1a (no unread marking from the pop-out) / S1b split acceptable + if the first PR runs long? 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. + +## 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). From 27dcad97e785eeb0659096dbf159951e0791b68b Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:31:29 -0500 Subject: [PATCH 03/80] =?UTF-8?q?docs:=20pop-out=20design=20revision=203?= =?UTF-8?q?=20=E2=80=94=20close=20guards,=20voluntary=20vs=20revoked=20exi?= =?UTF-8?q?ts,=20delayed=20reading=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- docs/popout-windows-design.md | 50 ++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md index 352fd77960..c3ae573c5a 100644 --- a/docs/popout-windows-design.md +++ b/docs/popout-windows-design.md @@ -229,11 +229,17 @@ 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, retirement by main) runs the same guards as closing a tab: pending -attachment drafts or an unresolved conflict → confirm dialog naming what would be -lost. Retirement forced by scope change (§5.8) closes without prompting but -posts the loss in the main toast. A later "move" variant needs an acknowledged -handoff that keeps the source until the destination exists. +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 @@ -267,8 +273,9 @@ is the main reason the S1 estimate rose (§5.8). - 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, close pop-outs first (drafts are already persisted on - each change). Alternative (hide main on close, as macOS) is a separate + 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 @@ -295,9 +302,13 @@ 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. Tests as listed. If this pushes S1 over budget -it ships as **S1b** before S2, because "don't lose the message" needs unread to -be truthful. +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 @@ -309,10 +320,11 @@ main. ### 5.8 Community or scope changes -Projection carries `scope`. Switching community, sign-out, relay-session -replacement, membership loss, channel archive → main retires the pop-outs of the -affected scope (`update(id, null)` → native `destroy()`), with one toast in main -naming what closed. Identical to the Huddle rule set. +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 @@ -396,8 +408,9 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. 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? And is an S1a (no unread marking from the pop-out) / S1b split acceptable - if the first PR runs long? + 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. @@ -414,3 +427,8 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. 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). From b119380235bc7c1c9be6db109b2a520acf0c4021 Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:20:21 -0500 Subject: [PATCH 04/80] docs: pop-out S1 scope checkpoint Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- docs/popout-windows-design.md | 118 ++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md index c3ae573c5a..b3396370f0 100644 --- a/docs/popout-windows-design.md +++ b/docs/popout-windows-design.md @@ -432,3 +432,121 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. 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). + +## 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). Implementation owner: jaw (accountable human +author); Jaw-fable51-pi implements and commits as jaw's git identity with a +`Co-authored-by` trailer. Reviewers per CODEOWNERS: `@block/buzz-oss-team`. + +### 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 (S1b, same PR unless it must split): 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. If split, S1a ships + with "pop-out does not clear unread" stated in `buzz-help.md` as an interim + product state (accepted by jaw 2026-11-04). +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. + +### 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. +- 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 (S1b) | 150–250 | +| **Total** | **1,450–2,050** (≈1,300–1,800 without S1b) | + +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. From c5709d47e9033d03b3037de5a31075ba8267e1cd Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:20:40 -0500 Subject: [PATCH 05/80] docs: checkpoint roles, unread in S1, sharing rule Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- docs/popout-windows-design.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/popout-windows-design.md b/docs/popout-windows-design.md index b3396370f0..f8b54d1662 100644 --- a/docs/popout-windows-design.md +++ b/docs/popout-windows-design.md @@ -436,9 +436,13 @@ restart, browser/mobile hosts, Linux/Windows hide-on-close, keyboard shortcut. ## 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). Implementation owner: jaw (accountable human -author); Jaw-fable51-pi implements and commits as jaw's git identity with a -`Co-authored-by` trailer. Reviewers per CODEOWNERS: `@block/buzz-oss-team`. +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) @@ -468,12 +472,12 @@ author); Jaw-fable51-pi implements and commits as jaw's git identity with a 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 (S1b, same PR unless it must split): 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. If split, S1a ships - with "pop-out does not clear unread" stated in `buzz-help.md` as an interim - product state (accepted by jaw 2026-11-04). +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. @@ -501,6 +505,10 @@ after first native tryout, per jaw). (`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 @@ -531,8 +539,8 @@ Modified: `src-tauri/src/lib.rs` (register commands, close handling), | 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 (S1b) | 150–250 | -| **Total** | **1,450–2,050** (≈1,300–1,800 without S1b) | +| 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 From 72ceb0a54171defd9687b8e5aab735ba58702bfe Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:33:27 -0500 Subject: [PATCH 06/80] feat(desktop): native pop-out conversation window host Presentation-only companion windows (popout-) modeled on the Huddle window: main owns identity, relay, signing and read state; each window is bound to one target and session incarnation; watch/action/download are gated per label; guarded close asks the companion; Cmd+W closes a focused pop-out; a main renderer reload detaches companions instead of leaving them able to act. Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- popout.html | 2 + src-tauri/build.rs | 5 + src-tauri/capabilities/default.json | 3 + src-tauri/capabilities/popout.json | 23 ++ src-tauri/src/close_menu.rs | 5 + src-tauri/src/huddle_window.rs | 42 +- src-tauri/src/lib.rs | 16 + src-tauri/src/popout_window.rs | 607 ++++++++++++++++++++++++++++ src-tauri/src/relay.rs | 3 + vite.config.ts | 1 + 10 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 popout.html create mode 100644 src-tauri/capabilities/popout.json create mode 100644 src-tauri/src/popout_window.rs diff --git a/popout.html b/popout.html new file mode 100644 index 0000000000..c2226280f2 --- /dev/null +++ b/popout.html @@ -0,0 +1,2 @@ + +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..a5dd007de8 100644 --- a/src-tauri/src/huddle_window.rs +++ b/src-tauri/src/huddle_window.rs @@ -57,13 +57,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)] @@ -84,19 +85,19 @@ struct DiscussionRow { } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct DiscussionAttachment { +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 +106,22 @@ 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) + } +} impl DiscussionAttachment { fn valid(&self) -> bool { matches!(self.kind.as_str(), "image" | "video" | "audio" | "file") @@ -226,20 +243,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() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49286782fa..ff8c73558c 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,15 @@ 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; } + } + if matches!(event, tauri::WindowEvent::Destroyed) { popout_window::destroyed(window); } 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")] diff --git a/src-tauri/src/popout_window.rs b/src-tauri/src/popout_window.rs new file mode 100644 index 0000000000..45f4129b4b --- /dev/null +++ b/src-tauri/src/popout_window.rs @@ -0,0 +1,607 @@ +//! 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, + dark: bool, + status: String, + #[serde(default)] + error: Option, + writable: bool, + can_load_more: bool, + limited: bool, + loading_older: bool, + #[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, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Action { + OpenLink, + OpenInMain, + Send, + 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, + /// Reading observation: text is a bounded JSON lease message for main. + Reading, + 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()) + } +} +fn validate(view: &View) -> Result<()> { + if view.title.len() > 4096 + || view.error.as_ref().is_some_and(|e| e.len() > 8192) + || !matches!(view.status.as_str(), "loading" | "ready" | "error") + || view + .composer + .as_ref() + .is_some_and(|t| uuid::Uuid::parse_str(t).is_err()) + || view.replies.len() > 500 + || 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)?; + 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() + .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(&format!("{} — Buzz", session.view.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::Send | Action::RetryMessage | Action::DiscardMessage | Action::Reading => { + 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()); + } + } + _ => { + 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 { + Action::Guard => { + session.guarded = text.as_deref() == Some("1"); + return Ok(()); + } + Action::Close => { + drop(map); + if let Some(companion) = app.get_webview_window(&label) { + companion.destroy().map_err(|e| e.to_string())?; + } + return Ok(()); + } + Action::OpenInMain => { + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + } + _ => {} + } + 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; + }; + let Ok(mut map) = host.0.lock() else { + return false; + }; + let Some(session) = map.get_mut(window.label()) else { + return false; + }; + if !session.guarded { + return false; + } + api.prevent_close(); + session.view.close_requested += 1; + if let Some(updates) = &session.updates { + let _ = updates.send(session.view.clone()); + } + true +} + +/// 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(), + dark: false, + status: "ready".into(), + error: None, + writable: true, + can_load_more: false, + limited: false, + loading_older: false, + composer: None, + version: 1, + close_requested: 0, + detached: false, + root: None, + replies: vec![], + } + } + #[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 labels_are_recognized_by_prefix() { + assert!(is_popout("popout-1234")); + assert!(!is_popout("main")); + assert!(!is_popout("huddle")); + } + #[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/vite.config.ts b/vite.config.ts index 050b33e89c..caf4c15025 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -238,6 +238,7 @@ export default defineConfig(async ({ command, mode }) => { compute: fileURLToPath( new URL("compute-widget.html", import.meta.url), ), + popout: fileURLToPath(new URL("popout.html", import.meta.url)), }, }, }, From bacca48d56e4021a7c53b3365347ddf3635ee9dc Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:46:15 -0500 Subject: [PATCH 07/80] feat(popout): thread pop-out windows as a bundled plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main-window owner projects a session-owned thread view into a pop-out companion, reusing the Huddle composer bridge (now with thread reply and thread draft keys) and a shared companion row projection/renderer. The companion mounts the real composer, reports held work for native close guards, and measures reading with useReading over a lease proxy whose leases live in main. Entry point: message menu → Open thread in new window. Co-authored-by: Jaw-fable51-pi <27b9430246cf506d04fedbbb39d0126ad17496a0e41bd82d39e35860de80f3d0@buzz.block.builderlab.xyz> Signed-off-by: Jonathan Andrew Wolter <44742+jawspeak@users.noreply.github.com> --- src-tauri/src/popout_window.rs | 3 + src/bundled/huddles/HuddleComposer.tsx | 56 ++- src/bundled/huddles/HuddleDiscussionView.tsx | 131 +------ src/bundled/index.ts | 7 + src/bundled/popout/Popout.module.css | 40 ++ src/bundled/popout/PopoutWindowView.tsx | 196 ++++++++++ src/bundled/popout/README.md | 9 + src/bundled/popout/index.tsx | 99 +++++ src/bundled/popout/manifest.json | 10 + src/bundled/popout/reading-proxy.ts | 44 +++ src/bundled/popout/window-entry.tsx | 64 +++ src/features/companion/CompanionRows.tsx | 154 ++++++++ src/features/companion/row.ts | 105 +++++ src/features/huddle/composer-client.ts | 29 +- src/features/huddle/composer-contract.ts | 14 + src/features/huddle/composer-owner.ts | 47 ++- src/features/huddle/discussion.ts | 114 +----- src/features/messages/MessageComposer.tsx | 9 + src/features/messages/use-reading.ts | 7 +- src/features/popout/thread.ts | 136 +++++++ src/features/popout/window.ts | 389 +++++++++++++++++++ 21 files changed, 1410 insertions(+), 253 deletions(-) create mode 100644 src/bundled/popout/Popout.module.css create mode 100644 src/bundled/popout/PopoutWindowView.tsx create mode 100644 src/bundled/popout/README.md create mode 100644 src/bundled/popout/index.tsx create mode 100644 src/bundled/popout/manifest.json create mode 100644 src/bundled/popout/reading-proxy.ts create mode 100644 src/bundled/popout/window-entry.tsx create mode 100644 src/features/companion/CompanionRows.tsx create mode 100644 src/features/companion/row.ts create mode 100644 src/features/popout/thread.ts create mode 100644 src/features/popout/window.ts diff --git a/src-tauri/src/popout_window.rs b/src-tauri/src/popout_window.rs index 45f4129b4b..916d61198a 100644 --- a/src-tauri/src/popout_window.rs +++ b/src-tauri/src/popout_window.rs @@ -59,6 +59,8 @@ pub(crate) struct View { can_load_more: bool, limited: bool, loading_older: bool, + /// Main publishes reading intent for this window when true. + reading: bool, #[serde(default)] composer: Option, version: u64, @@ -535,6 +537,7 @@ mod tests { can_load_more: false, limited: false, loading_older: false, + reading: true, composer: None, version: 1, close_requested: 0, diff --git a/src/bundled/huddles/HuddleComposer.tsx b/src/bundled/huddles/HuddleComposer.tsx index 4e402200b5..21c20f9f40 100644 --- a/src/bundled/huddles/HuddleComposer.tsx +++ b/src/bundled/huddles/HuddleComposer.tsx @@ -1,10 +1,30 @@ import { MentionModelsContext } from "../../features/agents/react"; -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { createHuddleComposerClient } from "../../features/huddle/composer-client"; +import { + attachmentDraftCount, + subscribeAttachmentDraftCounts, +} from "../../features/messages/attachment-draft"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { huddleComposerExtensions } from "./composer-extensions"; -export function useHuddleComposer(token: string | undefined) { +/** 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; +}; +export function useHuddleComposer( + token: string | undefined, + presentation?: CompanionComposerPresentation, +) { const [client, setClient] = useState>(); useEffect(() => { @@ -16,12 +36,16 @@ export function useHuddleComposer(token: string | undefined) { setClient(next); return () => next.dispose(); }, [token]); - return client ? : null; + return client ? ( + + ) : null; } function ConnectedComposer({ client, + presentation, }: { client: ReturnType; + presentation?: CompanionComposerPresentation | undefined; }) { useSyncExternalStore(client.subscribe, client.revision); const { session, data, error } = client.snapshot(); @@ -39,6 +63,24 @@ function ConnectedComposer({ gifRequests: client.gifs, }); }, [keys, client]); + // 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}`}` + : ""; + const files = useSyncExternalStore( + useCallback( + (listener: () => void) => + session ? subscribeAttachmentDraftCounts(session, listener) : () => {}, + [session], + ), + () => (session ? attachmentDraftCount(session, draftKey) : 0), + ); + const onHeld = presentation?.onHeld; + const held = editorHeld || files > 0; + useEffect(() => { + onHeld?.(held); + }, [onHeld, held]); if (error) return

{error}

; return session && data ? ( @@ -47,9 +89,11 @@ function ConnectedComposer({ extensions={extensions} scope={data.scope} channelId={data.room} - channelName="Huddle" - label="Message this Huddle" - placeholder="Message this Huddle" + channelName={presentation?.channelName ?? "Huddle"} + label={presentation?.label ?? "Message this Huddle"} + placeholder={presentation?.placeholder ?? "Message this Huddle"} + {...(data.thread ? { threadRootId: data.thread } : {})} + onHeld={onHeld ? setEditorHeld : undefined} disabled={!data.writable} editMessages={[]} /> 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/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/popout/Popout.module.css b/src/bundled/popout/Popout.module.css new file mode 100644 index 0000000000..d02bec584a --- /dev/null +++ b/src/bundled/popout/Popout.module.css @@ -0,0 +1,40 @@ +.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; +} +.title { + flex: 1; + min-width: 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); +} +.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; +} diff --git a/src/bundled/popout/PopoutWindowView.tsx b/src/bundled/popout/PopoutWindowView.tsx new file mode 100644 index 0000000000..ba9224f80a --- /dev/null +++ b/src/bundled/popout/PopoutWindowView.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + PopoutAction, + PopoutView, + ReadingMessage, +} from "../../features/popout/window"; +import { CompanionRows } from "../../features/companion/CompanionRows"; +import { MessageEditScope } from "../../features/messages/MessageEditScope"; +import { Reading } from "../../features/messages/use-reading"; +import { useHuddleComposer } from "../huddles/HuddleComposer"; +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 { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; +import { ChatCircleIcon } from "../../shared/design-system/icons"; +import { createReadingProxy } from "./reading-proxy"; +import styles from "./Popout.module.css"; + +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); + const rows = useMemo( + () => (view.root ? [view.root, ...view.replies] : view.replies), + [view.root, view.replies], + ); + 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 composer = useHuddleComposer( + view.detached ? undefined : view.composer, + { + channelName: view.title, + label: "Reply to thread", + placeholder: "Reply to thread", + onHeld, + }, + ); + const focusConversation = () => { + const editor = composerHost.current?.querySelector( + '[role="textbox"]:not([aria-disabled="true"]), textarea:not(:disabled)', + ); + (editor ?? log.current)?.focus({ preventScroll: true }); + }; + const reading = useMemo( + () => + createReadingProxy( + (message: ReadingMessage) => act("reading", JSON.stringify(message)), + () => view.reading && !view.detached, + ), + [act, view.reading, view.detached], + ); + const readingSession = useMemo(() => ({ unread: reading }), [reading]); + return ( + +
+ act("openInMain")}> + Open in main window + + } + /> + {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; + }} + > + + {view.limited && ( +

Showing the latest 500 replies.

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

+ Loading thread… +

+ ) : view.status === "ready" && !view.error ? ( + } title="No messages yet" /> + ) : null)} + { + act("openLink", url); + return true; + }} + recover={(id, dismiss) => + act(dismiss ? "discardMessage" : "retryMessage", id) + } + focusConversation={focusConversation} + /> +
+
+ {view.error && ( +
+ {view.error}{" "} + +
+ )} + {view.writable && !view.detached ? ( +
+ {composer} +
+ ) : ( + view.status === "ready" && + !view.detached && ( +

This thread is read-only here.

+ ) + )} + {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.tsx b/src/bundled/popout/index.tsx new file mode 100644 index 0000000000..5aa9c8f468 --- /dev/null +++ b/src/bundled/popout/index.tsx @@ -0,0 +1,99 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { useEffect, useSyncExternalStore } from "react"; +import { buzzLinkTarget } from "../../features/navigation/buzz-links"; +import type { PluginModule } from "../../plugins/api"; +import { + createPopoutWindows, + type PopoutWindows, +} from "../../features/popout/window"; +import type { ChannelMessage } from "../../features/relay/contracts"; +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"]; + +/** 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 windows = createPopoutWindows(ctx.relay, { + extensions: ctx.conversation, + openLink, + agents: ctx.agents, + }); + 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: () =>