Skip to content

feat(windows): open N peer windows kept in sync by a Store change feed - #928

Merged
matt2e merged 9 commits into
mainfrom
multi-windows
Aug 27, 2026
Merged

feat(windows): open N peer windows kept in sync by a Store change feed#928
matt2e merged 9 commits into
mainfrom
multi-windows

Conversation

@matt2e

@matt2e matt2e commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Every window becomes a full peer copy of the app, and a Store-level change feed keeps them consistent: a write in any window — or in the backend itself — reaches every other window and every web client.

Windows

  • A new_window command builds win-N windows from the parsed tauri.conf.json main-window entry, cascaded from the opener, behind File ▸ New Window (Cmd+Shift+N; Cmd+N stays New Project so the native accelerator can't swallow the keydown). A Tauri-only app-new-window shortcut gives Windows/Linux the same affordance.
  • The capability glob widens to ["main", "win-*"] — without it invoke fails and the window never shows — and win-* labels are excluded from window-state tracking so stale geometry entries don't accumulate for labels the plugin can't remove.
  • Menu events target the focused window instead of fanning out, and open a new window natively when nothing is focused.
  • Per-window identity: the PR-poll client id becomes tauri-<label> (first window stays tauri-main) so each window's selected project and focus count independently in the scheduler's union, with the tauri- namespace reserved at the web boundary. Last-viewed-project is keyed per window; only the first window restores it on cold start, and a new window opens on its opener's project via a one-shot seed handed through new_window.

Change feed

  • Every mutating store method publishes a domain-vocabulary StoreChange (Project / Branch / Notes / Review / Repos) — 65 methods across 13 store modules. The Store stays Tauri-agnostic behind an optional broadcast sender, so its unit tests are untouched.
  • A coalescer above the Tauri boundary dedupes identical changes on a 50ms window and forwards the event set to every window and WebSocket client; on broadcast lag it flushes an all-null invalidation ("refetch everything"), pinned by a test to the exact event set event_for can emit.
  • Session-family writes deliberately publish nothing — chat polls at 500ms and session lifecycle has its own events. Publishes are gated on real movement, so PR-status and workspace-status pollers don't churn the feed in steady state.

Frontend

  • The feed supersedes the imperative staleness workarounds, which are retired. Event-driven branch refetches are gated on real hydration rather than mere map membership, and cache drops scope to the project the payload names.
  • Feed-driven refetches bypass cached reads, so a project-changed in web mode can't be answered by a fresh-by-TTL list_projects / list_project_repos entry holding pre-mutation data.
  • Web mode revalidates on WebSocket reconnect. App-menu wiring moves out of App.svelte into menuListener.ts, and the window label is read once and fails loudly when it can't be.

Test coverage added for the coalescer, window commands, menu listener, cache invalidation, navigation, and the store change publishes. CI (crates-fmt/lint/test, differ-ci, staged-ci) is green on the pushed branch.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94e09d2b48

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +95 to +99
listenToEvent<NotesChangedEvent>('notes-changed', (payload) => {
if (payload.branchId) {
invalidateBranchTimeline(payload.branchId);
} else {
window.dispatchEvent(new CustomEvent('project-notes-invalidated'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh child-note aggregates on branch note changes

When a project session creates or completes a child_note in another window, the store publishes notes-changed with that note's branchId, so this handler only invalidates the branch timeline. Child notes are deliberately excluded from that timeline, while ProjectSection fetches them with listChildNotes() only when the parent note opens; consequently, an already-open parent note never sees the new or updated child until it is closed and reopened. Route child-note changes to a refresh of the open project-note aggregate as well.

Useful? React with 👍 / 👎.

matt2e and others added 9 commits August 26, 2026 16:43
Every window is a full peer copy of the app, and a Store-level change feed
keeps them consistent: a write in any window — or in the backend itself —
reaches every other window and every web client.

Windows: a `new_window` command builds `win-N` windows from the parsed
tauri.conf.json main-window entry, cascaded from the opener, behind
File ▸ New Window (Cmd+N; New Project moves to Shift+Cmd+N so the native
accelerator can't swallow the keydown). The capability glob widens to
["main", "win-*"] — without it invoke fails and the window never shows —
and win-* labels are excluded from window-state tracking, so stale geometry
entries never accumulate for labels the plugin can't remove. Menu events
target the focused window instead of fanning out across windows, and open a
new window natively when nothing is focused. Per-window identity: the
PR-poll client id becomes `tauri-<label>` (the first window stays
`tauri-main`) so each window's selected project and focus count
independently in the scheduler's union, with the `tauri-` namespace
reserved at the web boundary; last-viewed-project is keyed per window,
only the first window restores it on cold start, and a new window opens on
its opener's project via a one-shot seed handed through `new_window`.

Change feed: every mutating store method publishes a domain-vocabulary
`StoreChange` (Project / Branch / Notes / Review / Repos) — 65 methods
across 13 store modules. The Store stays Tauri-agnostic behind an optional
broadcast sender, so its unit tests are untouched; a coalescer above the
Tauri boundary dedupes identical changes on a 50ms window and forwards the
event set to every window and WebSocket client. Session-family writes
deliberately publish nothing — chat polls at 500ms and session lifecycle
has its own events. Publishes are gated on real movement: the PR-status and
workspace-status writes emit only when a domain field actually changes, so
the pollers stop churning the feed in steady state.

Frontend: the change feed supersedes the imperative staleness workarounds,
which are retired. Event-driven branch refetches are gated on real
hydration rather than mere map membership, and a branch-changed cache drop
is scoped to the project the payload names instead of every cached branch
list. Web mode revalidates on WebSocket reconnect and flushes an all-null
invalidation when the feed lags. App-menu wiring moves out of App.svelte
into menuListener.ts, and the window label is read once and fails loudly
when it can't be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The store change feed's project-changed event triggered
projectsDataStore.revalidate(), but in web mode that refetch reads
through cachedCommand (list_projects at a 5min TTL, list_project_repos
at 10min during rehydration). A fresh-by-TTL entry answered the
event-driven refetch with the pre-mutation data, so a project created,
renamed, or deleted in a native window (or another tab) stayed
invisible to a web client until the TTL expired — the exact staleness
the feed exists to eliminate. Tauri windows were unaffected since
cachedCommand bypasses the cache there.

Give project-changed a cache leg in cacheInvalidationListener,
mirroring the branch handler: list_projects drops command-wide (it
takes no args), and list_project_repos scopes to the named project,
widening to every project only on the feed's lag recovery (null
projectId). Tests cover both tiers.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ft+Cmd+N

The previous multi-window change gave File > New Window the plain Cmd+N
accelerator and pushed New Project onto Shift+Cmd+N, and the only caller
of new_window lived in the macOS-only menu block — so Windows/Linux
builds lost their new-project chord and gained no way to open a second
window at all (flagged in review 484f9b1c).

Swap the chords back and give the command a cross-platform entry point:

- The macOS File > New Window accelerator becomes Cmd+Shift+N, so the
  native menu no longer consumes plain Cmd+N before the webview sees it.
- app-new-project returns to Cmd+N, and the sidebar/list shortcut hints
  follow ("New project (Cmd+N)").
- A Tauri-only app-new-window shortcut (Cmd+Shift+N) registers in
  App.svelte, calling commands.newWindow with the current window's
  selected project — the same seed the macOS menu path passes. On macOS
  the native accelerator still wins and routes through menuListener, so
  the binding is effectively the Windows/Linux affordance the review
  found missing; in web mode it is omitted since the web server rejects
  new_window.

svelte-check, cargo check, and the frontend test suite (696 tests) pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
lag_flush_events and event_for each enumerate the same five wire event
names, but nothing coupled them: a future StoreChange variant wired into
event_for (and the frontend) but forgotten in lag_flush_events would
silently lose lag recovery for exactly that surface — the staleness bug
the flush exists to prevent, and one no test pinned (flagged in review
484f9b1c).

Add a test asserting the event-name set of lag_flush_events equals the
set produced by mapping every StoreChange variant through event_for. The
variant list comes from a helper whose wildcard-free match over
StoreChange makes a new variant a compile error in the test module until
the helper — and therefore the assertion, and therefore
lag_flush_events — is updated to match.

cargo test store_events passes (4 tests); fmt clean; the two clippy
warnings under --tests are pre-existing in test_utils.rs and
store/tests.rs.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Every window is built from the same `main` entry in tauri.conf.json, which
hard-codes `"title": "Staged"`, and nothing ever called `set_title` — so
AppKit's auto-populated Window submenu listed `Staged` once per window and
told them apart not at all. Name each window after what it is filtered
down to, per the plan note (steps 1-4; step 5 is manual GUI verification).

The signal is the filter selection: `projectFiltersStore` is module-scoped
and every window is its own webview with its own module graph, so it is
already window-local, and it is not persisted, so a cold window starts at
the default title with no restore path.

- `features/layout/windowTitle.ts` holds a pure `formatWindowTitle(Set)`:
  status filters first in a fixed Unread-then-Running order, then repo
  filters, joined with ` · ` (repos among themselves with `, `), capped at
  two named repos plus `+N more`; the empty set returns
  DEFAULT_WINDOW_TITLE. Repos are named from their filter key alone — never
  from `repoBadgeStore`, whose async badge load would make the title flip
  mid-session — so a stale filter naming a since-deleted repo still titles
  the window, matching `activeRepoFilters`' independence from hydration.
- The emphasis rule moves to `shared/repoLabel.ts` and `RepoLabel.svelte`
  now consumes it, so the chip and the title can't drift.
- `applyWindowTitle`, alongside the formatter, dedupes unchanged titles (so
  the effect's first run, which always recomputes the default the window
  already has, issues no IPC) and serializes calls behind the previous one
  with a post-await generation check, so rapid toggling can't let a
  superseded title land last.
- `WindowHandle` gains `setTitle`; the Tauri branch forwards to the real
  window and `noopWindow` sets `document.title`, which is a genuine win in
  web mode where the browser tab is the window list.
- `core:window:allow-set-title` joins capabilities/default.json. The note is
  right that it is absent from tauri's `core:window` default set (verified
  against gen/schemas/acl-manifests.json: the default set is getters only —
  `allow-title` yes, `allow-set-title` no), and its absence fails at runtime
  rather than at build time, so a Rust test pins both the permission and the
  `win-*` window glob that extends it to secondary windows.
- A third top-level `$effect` in App.svelte wires the two together.

One correction to the note: its format table renders `block/builderbot` +
subpath `apps/staged` as `staged`, but RepoLabel's actual rule — the one the
note asks to extract and share — emphasises the *whole* subpath, so the
title reads `apps/staged`. Changing the shared helper to the last segment
would silently restyle the existing repo chips, so the code's rule wins;
the two readings only differ for multi-segment subpaths. Windows/Linux will
show these titles in the native titlebar, since `titleBarStyle`/`hiddenTitle`
are macOS-only — accepted, per the note's risk section.

Verification: `npm test` 731 tests across 60 files pass (13 new in
windowTitle.test.ts covering the format table, status ordering independent
of insertion order, subpath emphasis, the `+N more` cutoff, stale filters,
and applyWindowTitle's dedupe/last-write-wins/error guards); `npm run
check` (svelte-check + tsc) reports 0 errors, 0 warnings over 4859 files;
`cargo test window_commands` passes 5 tests including the new capability
pin; `cargo fmt --check` and prettier are clean. The Window-menu behaviour
itself (AppKit still showing NSWindow.title under a hidden title bar) is
the note's step 5 and remains unverified by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
`delete_branch` and `delete_project_repo` both resolve their enrichment id
before the row goes and comment that it is "published only if the delete
lands" — but neither checked `conn.execute`'s row count, so the publish was
unconditional. Deleting an already-gone row (a realistic race now that two
windows can act on the same branch) resolved the id to `None` and published
the frontend's *widest* tier: a command-wide `list_branches_for_project` /
`list_project_repos` cache drop plus a refetch in every window. It
self-healed, but it turned a lost race into a fleet-wide refetch (flagged in
review be789859).

Guard both publishes on rows affected, as `delete_note` and `delete_review`
already do, so the code matches its comment and the losing racer stays
silent. A change-feed test pins both halves: the delete that lands still
carries the pre-delete enrichment, the repeat delete publishes nothing.

Also close the automated gap the same review flagged around
`getWindowLabel()`. It reads Tauri's private `__TAURI_INTERNALS__` (the label
must be available synchronously at module-init time, which `getCurrentWindow()`
can't offer), so an upgrade that reshapes the internals silently collapses
every window to the shared `main` identity — until now caught only by a
console tripwire in front of a user. `getCurrentWindow()` resolves the label
from the same globals off the same shape, so a new test points the real,
unmocked package at the stub `getWindowLabel()` expects and asserts the two
agree; a reshaping bump fails `npm test` instead. Verified non-vacuous by
reshaping the stub — the test fails at the moved property read.

Verification: `cargo test change_feed` passes 7 tests (1 new); `npm test`
passes 737 across 60 files (1 new); `npm run check` reports 0 errors over
4860 files; `cargo fmt --check` and prettier are clean. The two clippy
warnings under `--tests` remain pre-existing (test_utils.rs:16,
store/tests.rs:1201).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit 582a0be into main Aug 27, 2026
2 checks passed
@matt2e
matt2e deleted the multi-windows branch August 27, 2026 04:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant