EPIC: Project Switching Navigation
Multi-window project navigation for the Relay macOS client: close project returns to Welcome, File/Window menus allow quick project switching, opening another project shows a prompt (same window / new window / cancel with "remember"), multiple windows with the same project are prevented via SwiftUI WindowGroup(for: ProjectID.self) and a unified open-handler for external triggers.
Goal
Make project management fluid: users can close a project without quitting the app, switch between recent/open projects from menus, and never accidentally open the same project in two windows.
Background & Decisions
Based on the research report at swarm-report/project-switching-research.md (not in repo — swarm-report/ is gitignored; see local dev workspace or request a reviewer share it). The following design decisions are locked:
- Approach A: SwiftUI multi-window.
Window(id: "welcome") for singleton Welcome; WindowGroup(for: ProjectID.self) for project windows. Rejected: Approach D (single-window multi-tab, 3× cheaper but doesn't match literal requirement "in this window / in a new window").
- Hybrid
ProjectID migration. Add canonicalPath: String to Project without replacing the existing UUID — lazy backfill during first load. Low-regression, easily rollbackable.
- Identity. Two clones of the same repo = two separate projects (path-based identity, no git remote inference).
- Soft-limit 8 open windows (warning, no hard limit).
- Worktree = separate window (different canonical paths).
- Default prompt every time on conflicting context, with "Remember choice" checkbox; Preferences pane to reset.
- Switch "in this window" reuses the existing
closeProjectAlert when active sessions exist.
- URL scheme deferred out of MVP (basic infra in T-14).
- macOS 26 / WWDC25: verified — no breaking changes in multi-window SwiftUI API;
WindowGroup(for:) dedup-by-value and scene restoration are stable.
Requirements (from user request)
- Closing a project returns to Welcome (not closing the whole app).
- Menu navigation between projects — File → Open Recent + Window → Open Projects.
- When opening another project, prompt — "In this window / In a new window / Cancel", with a checkbox to remember.
- Never allow two windows with the same project — activate the existing window instead.
MVP Scope
MVP = T-1 through T-15 (14 tasks, ~8–10 eng-days). T-14 (external open triggers) and T-15 (session isolation diagnostic) are NOT originally part of M1+M2+M3 but are required for requirement 4 to hold in practice — without T-14, dragging a folder onto the Dock icon bypasses the dedup guard; without T-15, terminal sessions may leak across windows.
Deferred (post-MVP): T-16 (multi-monitor polish), T-17 (accessibility deep pass), T-18 (ghost project folder), T-19 (extend reopenLastProject to reopenAllWindows).
Milestones (logical grouping)
| Milestone |
Scope |
Closes requirements |
| M1. Foundation |
Canonical ProjectID + SwiftData migration + memory dedup via ProjectID + red-dot close alert |
— (infra) |
| M2. Multi-window core |
WindowFeature decomposition + WindowGroup(for: ProjectID.self) + per-window Welcome + single-instance guard + allowsAutomaticWindowTabbing |
Req 1, Req 4 |
| M3. Menu + switcher |
File → Open Recent + Window → Open Projects + @FocusedValue<WindowID> + confirmation sheet + Preferences pane |
Req 2, Req 3 |
| M4. External triggers |
Unified application(_:open:) handler + URL scheme placeholder |
Req 4 edge cases |
| M5. Polish |
Terminal session isolation tests + multi-monitor + accessibility + ghost projects |
— (QA) |
| M6. Restoration |
Extend reopenLastProject → reopenAllWindows |
— (QoL) |
Task List (19 tasks)
| # |
Issue |
Task |
Wave |
Complexity |
Depends on |
| T-1 |
#194 |
Introduce ProjectID canonical identity value type |
1 |
S |
— |
| T-2 |
#196 |
Extend Project model with canonicalPath + SwiftData migration |
2 |
M |
#194 |
| T-3 |
#197 |
Replace .path equality with ProjectID across reducers |
2 |
S |
#194 |
| T-4 |
#195 |
Handle red-dot window close → closeProjectAlert |
1 |
M |
— |
| T-5 |
#198 |
Introduce WindowFeature reducer per window |
3 |
L |
#194, #197 |
| T-6 |
#200 |
Move serverList & cloudNavigation sheets into WindowFeature |
3 |
M |
#198 |
| T-7 |
#201 |
Switch RelayApp to WindowGroup(for:) + Window(id: "welcome") |
4 |
L |
#198, #200 |
| T-8 |
#202 |
Enable NSWindow.allowsAutomaticWindowTabbing |
5 |
S |
#201 |
| T-9 |
#203 |
Introduce @FocusedValue<WindowID> for menu targeting |
5 |
M |
#201 |
| T-10 |
#209 |
File → Open Recent dynamic submenu |
5 |
M |
#203 |
| T-11 |
#204 |
Window → Open Projects dynamic list |
5 |
M |
#201 |
| T-12 |
#210 |
Confirmation sheet "In this / new / Cancel" + "Remember" |
5 |
L |
#203, #209, #204 |
| T-13 |
#211 |
Preferences pane — "When opening a project" picker |
5 |
S |
#210 |
| T-14 |
#205 |
Unified application(_:open:) open-handler |
5 |
M |
#194, #201 |
| T-15 |
#206 |
Terminal session isolation between windows (diagnostic + fix) |
5 |
M (up to L) |
#201 |
| T-16 |
#207 |
Multi-monitor positioning defaults |
5 |
S |
#201 |
| T-17 |
#212 |
Accessibility pass — VoiceOver across multi-window UX |
5 |
M |
#210 |
| T-18 |
#199 |
Ghost project handling (folder deleted externally) |
3 |
M |
#196 |
| T-19 |
#208 |
Extend reopenLastProject → reopenAllWindows |
5 |
M |
#201 |
Dependency Graph
T-1 (#194) ──→ T-2 (#196) ──→ T-18 (#199)
│ │
│ └──→ T-3 (#197) ──┐
│ │
└────────────────────────────────┴──→ T-5 (#198) ──→ T-6 (#200) ──→ T-7 (#201) ──┐
├──→ T-8 (#202)
├──→ T-9 (#203) ──→ T-10 (#209) ──→ T-12 (#210) ──→ T-13 (#211)
│ └──→ T-17 (#212)
├──→ T-11 (#204) ───────────────────────┘
├──→ T-14 (#205)
├──→ T-15 (#206)
├──→ T-16 (#207)
└──→ T-19 (#208)
T-4 (#195) (independent — red-dot close alert)
Risks
Technical
- SwiftData migration regressions — mitigated by hybrid approach (add
canonicalPath without replacing UUID).
- Terminal session leakage across windows via
sharedTerminalRegistry — T-15 diagnostic; if the test fails, scope TerminalSession by windowID.
WindowFeature refactor touches every reducer — recommend feature flag on RelayApp.body as rollback safety net.
.commands menu targeting in multi-window requires @FocusedValue — standard SwiftUI idiom.
ForEach inside CommandGroup is not explicitly documented by Apple but works in production (CodeEdit) — monitor regressions.
Product / UX
Acceptance Criteria (EPIC-level)
Epic is closed when:
Out of Scope
- Android / iOS / web clients (macOS client only)
- Full
NSDocument-based architecture (rejected: Approach B)
NSWindowController dictionary (rejected: Approach C)
- URL scheme beyond basic path parsing (deferred post-MVP)
- AppleScript full scripting support (deferred)
- Handoff via
NSUserActivity (deferred)
- Backend (Rust runner) changes — this is a pure frontend epic
References
EPIC: Project Switching Navigation
Multi-window project navigation for the Relay macOS client: close project returns to Welcome, File/Window menus allow quick project switching, opening another project shows a prompt (same window / new window / cancel with "remember"), multiple windows with the same project are prevented via SwiftUI
WindowGroup(for: ProjectID.self)and a unified open-handler for external triggers.Goal
Make project management fluid: users can close a project without quitting the app, switch between recent/open projects from menus, and never accidentally open the same project in two windows.
Background & Decisions
Based on the research report at
swarm-report/project-switching-research.md(not in repo —swarm-report/is gitignored; see local dev workspace or request a reviewer share it). The following design decisions are locked:Window(id: "welcome")for singleton Welcome;WindowGroup(for: ProjectID.self)for project windows. Rejected: Approach D (single-window multi-tab, 3× cheaper but doesn't match literal requirement "in this window / in a new window").ProjectIDmigration. AddcanonicalPath: StringtoProjectwithout replacing the existingUUID— lazy backfill during first load. Low-regression, easily rollbackable.closeProjectAlertwhen active sessions exist.WindowGroup(for:)dedup-by-value and scene restoration are stable.Requirements (from user request)
MVP Scope
MVP = T-1 through T-15 (14 tasks, ~8–10 eng-days). T-14 (external open triggers) and T-15 (session isolation diagnostic) are NOT originally part of M1+M2+M3 but are required for requirement 4 to hold in practice — without T-14, dragging a folder onto the Dock icon bypasses the dedup guard; without T-15, terminal sessions may leak across windows.
Deferred (post-MVP): T-16 (multi-monitor polish), T-17 (accessibility deep pass), T-18 (ghost project folder), T-19 (extend reopenLastProject to reopenAllWindows).
Milestones (logical grouping)
WindowGroup(for: ProjectID.self)+ per-window Welcome + single-instance guard +allowsAutomaticWindowTabbing@FocusedValue<WindowID>+ confirmation sheet + Preferences paneapplication(_:open:)handler + URL scheme placeholderreopenLastProject→reopenAllWindowsTask List (19 tasks)
ProjectIDcanonical identity value type.pathequality with ProjectID across reducersWindowGroup(for:)+Window(id: "welcome")NSWindow.allowsAutomaticWindowTabbing@FocusedValue<WindowID>for menu targetingapplication(_:open:)open-handlerDependency Graph
Risks
Technical
canonicalPathwithout replacing UUID).sharedTerminalRegistry— T-15 diagnostic; if the test fails, scopeTerminalSessionbywindowID.WindowFeaturerefactor touches every reducer — recommend feature flag onRelayApp.bodyas rollback safety net..commandsmenu targeting in multi-window requires@FocusedValue— standard SwiftUI idiom.ForEachinsideCommandGroupis not explicitly documented by Apple but works in production (CodeEdit) — monitor regressions.Product / UX
window.title= project name.closeProjectAlert).Window(id:)singleton, cannot duplicate.Acceptance Criteria (EPIC-level)
Epic is closed when:
mainopenCLI) activates the existing window (requirement 4) — manual test pass for all three triggersdocs/architecture/andMacApp/README updated with multi-window modelOut of Scope
NSDocument-based architecture (rejected: Approach B)NSWindowControllerdictionary (rejected: Approach C)NSUserActivity(deferred)References
swarm-report/project-switching-research.md(local;swarm-report/is gitignored)swarm-report/project-switching-decomposition.md(local)