Skip to content

Commit 9096ae2

Browse files
spec(036): tray-only startup; TrayIcon as a peer of Window (#194)
* docs(specs/036): support tray-only startup with no initial window Tray icons are process-scoped, not window-scoped. A class of apps (sync agents, chat clients, clipboard managers) wants to live in the tray with no visible window at startup and exit only via an explicit quit command. - Hoist OpenWindow / RegisterTrayIcon / FindWindow to ReactorApp statically so they're callable from tray-click handlers, MCP tools, and any post-startup code path - ReactorAppContext keeps the same surface as a forwarding facade and carries the LaunchActivation - Clarify §11.4 that tray icons survive window-close cycles and the hidden message window is internal - Document in §6.2 that the startup callback may open zero windows under ShutdownPolicy.Explicit - Add §13.6 example walking through the tray-only pattern: zero-window startup, single-instance window-on-demand via FindWindow + OpenWindow, reconciled tray flyout, ReactorApp.Exit as the sole termination path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs/036): make TrayIcon a peer of Window Tray icons and windows are both top-level OS surfaces with their own lifecycle, their own user input events, their own reconciled Reactor content, and either can be the app's user-facing entry point. The API should make that obvious. - Rename TrayIcon (handle) to ReactorTrayIcon to mirror ReactorWindow - Rename ReactorApp.RegisterTrayIcon to OpenTrayIcon (parallels OpenWindow) - Add ReactorApp.TrayIcons / FindTrayIcon / TrayIconOpened / TrayIconClosed - TrayIconSpec gains a Key field for FindTrayIcon identity - Rename ShutdownPolicy.OnLastWindowClosed to OnLastSurfaceClosed — tray icons count toward shutdown, not just windows - Expand §11.4 with a Window↔TrayIcon parity table - Update §13.6 sample to use the new OpenTrayIcon shape - Update §15 Q2 disposition with the peer framing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs/036): UseWindow returns null in tray-flyout content Tray-icon flyout content (§11.4) is reconciled into an internal hidden popup window, not a ReactorWindow. Document the contract for window-aware hooks called from that context. - UseWindow() return type changes to ReactorWindow? (was non-nullable) - Add §7.1 explaining when null is returned and a null-check example - UseWindowSize/UseDpi/UseWindowState/UseIsActive/UseClosingGuard each document their flyout-context fallback (zero size, system DPI, Normal state, active=true, no-op respectively) - Pre-mount calls remain unsupported (same constraint as every hook) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40a6d1a commit 9096ae2

1 file changed

Lines changed: 232 additions & 32 deletions

File tree

docs/specs/036-window-design.md

Lines changed: 232 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ ReactorApp process-scoped singleton
165165
├── UIDispatcher captured once at Application.Start
166166
├── Windows ReadOnlyList<ReactorWindow>
167167
├── PrimaryWindow the one passed to the startup callback (or null)
168-
├── ShutdownPolicy OnPrimaryWindowClosed | OnLastWindowClosed | Explicit
168+
├── ShutdownPolicy OnPrimaryWindowClosed | OnLastSurfaceClosed | Explicit
169169
└── WindowOpened/Closed process-level events
170170
171171
ReactorWindow owns one OS Window + one ReactorHost
@@ -331,31 +331,51 @@ public static partial class ReactorApp
331331
public static void Run(Action<ReactorAppContext> startup);
332332

333333
public static IReadOnlyList<ReactorWindow> Windows { get; }
334-
public static ReactorWindow? PrimaryWindow { get; }
334+
public static ReactorWindow? PrimaryWindow { get; } // null in tray-only apps
335335
public static DispatcherQueue UIDispatcher { get; }
336336
public static ShutdownPolicy ShutdownPolicy { get; set; }
337337
= ShutdownPolicy.OnPrimaryWindowClosed;
338338

339+
// Top-level surface operations — usable from anywhere on the UI thread
340+
// once Run has entered the startup callback. Tray click handlers, menu
341+
// commands, MCP tools, etc. all call into the same surface. Windows
342+
// and tray icons are peers; both can be the app's entry point.
343+
public static ReactorWindow OpenWindow(WindowSpec spec, Func<Component> root);
344+
public static ReactorWindow OpenWindow(WindowSpec spec, Func<RenderContext, Element> render);
345+
public static ReactorWindow? FindWindow(WindowKey key);
346+
347+
public static ReactorTrayIcon OpenTrayIcon(TrayIconSpec spec);
348+
public static IReadOnlyList<ReactorTrayIcon> TrayIcons { get; }
349+
public static ReactorTrayIcon? FindTrayIcon(WindowKey key);
350+
339351
public static event EventHandler<ReactorWindow>? WindowOpened;
340352
public static event EventHandler<ReactorWindow>? WindowClosed;
353+
public static event EventHandler<ReactorTrayIcon>? TrayIconOpened;
354+
public static event EventHandler<ReactorTrayIcon>? TrayIconClosed;
341355

342356
public static void Exit(int exitCode = 0);
343357

344358
[Obsolete("Use ReactorApp.PrimaryWindow.Host or ReactorApp.Windows.")]
345359
public static ReactorHost? ActiveHost { get; }
346360
}
347361

362+
// The startup-callback context is a thin facade over ReactorApp giving access
363+
// to the launch activation. It does not hold per-startup state; calls forward
364+
// to the static ReactorApp surface and remain valid after Run returns control.
348365
public sealed class ReactorAppContext
349366
{
367+
public LaunchActivation LaunchActivation { get; }
350368
public ReactorWindow OpenWindow(WindowSpec spec, Func<Component> root);
351369
public ReactorWindow OpenWindow(WindowSpec spec, Func<RenderContext, Element> render);
352370
public ReactorWindow? FindWindow(WindowKey key);
371+
public ReactorTrayIcon OpenTrayIcon(TrayIconSpec spec);
372+
public ReactorTrayIcon? FindTrayIcon(WindowKey key);
353373
}
354374

355375
public enum ShutdownPolicy
356376
{
357377
OnPrimaryWindowClosed,
358-
OnLastWindowClosed,
378+
OnLastSurfaceClosed,
359379
Explicit,
360380
}
361381
```
@@ -444,14 +464,27 @@ Process start
444464

445465
### 6.2 Shutdown policies
446466

467+
"Top-level surfaces" means **windows and tray icons** — both count
468+
toward shutdown decisions.
469+
447470
- **OnPrimaryWindowClosed** *(default)* — closing the primary window
448-
exits the process, regardless of secondary windows still open. This
449-
matches today's `Run<TRoot>` semantics.
450-
- **OnLastWindowClosed** — close the last window to exit. Secondary
451-
windows can outlive the primary.
452-
- **Explicit** — windows close, but the process keeps running until
453-
`ReactorApp.Exit()` is called. Useful for tray-resident apps (when
454-
tray support arrives) and for headless windows that re-spawn.
471+
exits the process, regardless of other windows or tray icons still
472+
open. Matches today's `Run<TRoot>` semantics. Picking this policy
473+
with zero initial windows would exit immediately.
474+
- **OnLastSurfaceClosed** — exit when the last window **and** the
475+
last tray icon have closed. The right default for apps where
476+
closing all visible surfaces should mean "I'm done with the app."
477+
Replaces what the previous draft called `OnLastWindowClosed`
478+
treating tray as a peer means it has to count.
479+
- **Explicit** — surfaces close, but the process keeps running until
480+
`ReactorApp.Exit()` is called. The supported policy for
481+
**tray-only startup** (§13.6), background sync agents, headless
482+
window respawn, and any other shape where "no surfaces open" is a
483+
valid running state.
484+
485+
The startup callback is allowed to open zero surfaces. `ReactorApp.Run`
486+
does not require at least one `OpenWindow` or `OpenTrayIcon` call —
487+
only that the selected `ShutdownPolicy` permits the resulting state.
455488

456489
### 6.3 Per-window teardown
457490

@@ -467,22 +500,29 @@ Process start
467500
## §7 Hooks
468501

469502
```csharp
470-
// Returns the ReactorWindow hosting the current component.
471-
ReactorWindow RenderContext.UseWindow();
503+
// Returns the ReactorWindow hosting the current component, or null
504+
// when the component renders outside a window (e.g. tray-icon flyout
505+
// content — see §7.1).
506+
ReactorWindow? RenderContext.UseWindow();
472507

473508
// DIP size of the host window; re-renders on resize.
509+
// Returns (0, 0) when called outside a window (e.g. tray-flyout content).
474510
(double Width, double Height) RenderContext.UseWindowSize();
475511

476512
// Per-monitor DPI; re-renders on DPI change.
513+
// Returns the system primary-monitor DPI when called outside a window.
477514
uint RenderContext.UseDpi();
478515

479516
// Window state; re-renders on minimize/maximize/restore/etc.
517+
// Returns Normal when called outside a window.
480518
WindowState RenderContext.UseWindowState();
481519

482520
// Activation; re-renders on activated/deactivated.
521+
// Returns true when called outside a window (the flyout is "active" while shown).
483522
bool RenderContext.UseIsActive();
484523

485524
// Confirmation gate for Closing — return false to cancel close.
525+
// No-op when called outside a window (no Closing event source).
486526
// The function runs synchronously on the UI thread; for async confirms,
487527
// cancel the close and re-issue programmatically when the user decides.
488528
void RenderContext.UseClosingGuard(Func<bool> canClose);
@@ -500,6 +540,38 @@ The mirror methods on `Component` (currently `UseWindowSize(Window)` /
500540
overloads. The explicit `Window`-typed overloads stay for back compat
501541
and for consumers that hold a reference to a non-Reactor `Window`.
502542

543+
### 7.1 Reaching the host window from a render
544+
545+
`UseWindow()` is the canonical answer to "which window is rendering
546+
me?". It does an O(1) field read on the current `ReactorHost` — no
547+
subscription, no re-render trigger. Use it whenever you need the
548+
window handle (open another window with this one as `Owner`, set
549+
taskbar progress, dispatch a window-level command, etc.). For
550+
behavior that should re-render on changes, use the targeted hooks:
551+
`UseWindowSize`, `UseDpi`, `UseWindowState`, `UseIsActive`.
552+
553+
**Returns `null` for tray-icon flyout content.** A tray icon's
554+
flyout (§11.4) is reconciled into a hidden internal popup window,
555+
not a `ReactorWindow`. Components that may render in either context
556+
should null-check:
557+
558+
```csharp
559+
class StatusBadge : Component
560+
{
561+
protected override Element Render()
562+
{
563+
var window = UseWindow();
564+
// Same component used inside the main window AND in the tray
565+
// flyout. The tray-flyout case has no window handle.
566+
var dpiHint = window is null ? "" : $" @ {window.Dpi}dpi";
567+
return Text($"Status: connected{dpiHint}");
568+
}
569+
}
570+
```
571+
572+
For components that only ever render inside a window (the common
573+
case), `UseWindow()!` is fine.
574+
503575
## §8 Persistence
504576

505577
`WindowSpec.PersistenceId` is the opt-in. When set:
@@ -683,14 +755,41 @@ process-arg parser the app can reuse.
683755

684756
### 11.4 System tray icon
685757

758+
A tray icon is a **peer of `ReactorWindow`**, not a feature attached
759+
to one. Both are top-level OS surfaces with their own lifetime,
760+
their own user-input events, their own reconciled Reactor content
761+
(the window's content tree, the tray's flyout), and either can be
762+
the app's user-facing entry point. The API mirrors `OpenWindow` /
763+
`ReactorWindow` to make that peerage obvious in code:
764+
765+
| Window | Tray icon |
766+
|-----------------------------------------|-----------------------------------------------|
767+
| `WindowSpec` | `TrayIconSpec` |
768+
| `ReactorApp.OpenWindow(spec, factory)` | `ReactorApp.OpenTrayIcon(spec)` |
769+
| `ReactorWindow` handle | `ReactorTrayIcon` handle |
770+
| `ReactorApp.Windows` | `ReactorApp.TrayIcons` |
771+
| `ReactorApp.FindWindow(key)` | `ReactorApp.FindTrayIcon(key)` |
772+
| `WindowOpened` / `WindowClosed` events | `TrayIconOpened` / `TrayIconClosed` events |
773+
| `Update(spec)` / `Close()` | `Update(spec)` / `Close()` |
774+
| Reconciles a content tree continuously | Reconciles flyout content on demand |
775+
776+
A tray icon does not have size, position, presenter, DPI awareness,
777+
persistence, owner, or modality — those are window-shaped concepts.
778+
Everything else is the same shape.
779+
686780
```csharp
687781
public sealed record TrayIconSpec(
688782
WindowIcon Icon,
689783
string Tooltip,
690-
bool ShowOnStart = true);
784+
WindowKey? Key = null,
785+
bool IsVisible = true);
691786

692-
public sealed class TrayIcon : IDisposable
787+
public sealed class ReactorTrayIcon : IDisposable
693788
{
789+
public string Id { get; }
790+
public WindowKey? Key { get; }
791+
public TrayIconSpec Spec { get; } // last applied snapshot
792+
694793
public WindowIcon Icon { get; set; }
695794
public string Tooltip { get; set; }
696795
public bool IsVisible { get; set; }
@@ -701,17 +800,23 @@ public sealed class TrayIcon : IDisposable
701800

702801
public void ShowFlyout(Element flyoutContent);
703802
public void HideFlyout();
803+
public void Update(TrayIconSpec spec);
804+
public void Close(); // == Dispose; removes the icon from the tray
704805
}
705806

706-
// On ReactorWindow:
707-
public TrayIcon RegisterTrayIcon(TrayIconSpec spec);
807+
// On ReactorApp / ReactorAppContext:
808+
public static ReactorTrayIcon OpenTrayIcon(TrayIconSpec spec);
809+
public static IReadOnlyList<ReactorTrayIcon> TrayIcons { get; }
810+
public static ReactorTrayIcon? FindTrayIcon(WindowKey key);
811+
public static event EventHandler<ReactorTrayIcon>? TrayIconOpened;
812+
public static event EventHandler<ReactorTrayIcon>? TrayIconClosed;
708813
```
709814

710-
The tray icon's flyout content goes through the reconciler exactly
711-
like the rest of Reactor — the API takes an `Element`, not a
712-
WinUI control. Implementation borrows from `WinUIEx.TrayIcon`
713-
(`Shell_NotifyIcon` + a hidden popup window for the flyout
714-
`XamlRoot`).
815+
The flyout content goes through the reconciler exactly like the rest
816+
of Reactor — the API takes an `Element`, not a WinUI control.
817+
Implementation borrows from `WinUIEx.TrayIcon` (`Shell_NotifyIcon` +
818+
a hidden popup window for the flyout `XamlRoot`); the hidden window
819+
is internal and never exposed to app code.
715820

716821
A common UX pattern is "minimize to tray". This is built on the
717822
public surface, not baked in:
@@ -741,8 +846,11 @@ class App : Component
741846
}
742847
```
743848

744-
`UseTrayIcon` is a thin hook over `RegisterTrayIcon` that disposes
745-
the icon on cleanup.
849+
`UseTrayIcon` is a thin hook over `ReactorApp.OpenTrayIcon` that
850+
calls `Close()` on the icon during the calling component's cleanup.
851+
For tray-only apps that have no component tree at startup (§13.6),
852+
call `ReactorApp.OpenTrayIcon` directly from the startup callback —
853+
the same way you'd call `OpenWindow`.
746854

747855
### 11.5 Thumbnail toolbar
748856

@@ -944,7 +1052,7 @@ class Editor : Component
9441052
### 13.5 Multi-window startup
9451053

9461054
```csharp
947-
ReactorApp.ShutdownPolicy = ShutdownPolicy.OnLastWindowClosed;
1055+
ReactorApp.ShutdownPolicy = ShutdownPolicy.OnLastSurfaceClosed;
9481056

9491057
ReactorApp.Run(ctx =>
9501058
{
@@ -961,6 +1069,96 @@ ReactorApp.Run(ctx =>
9611069
});
9621070
```
9631071

1072+
### 13.6 Tray-only startup — no initial window
1073+
1074+
A class of apps (chat clients, sync agents, clipboard managers,
1075+
quick-launchers) wants to live in the system tray with no visible
1076+
window at startup. The user opens a window on demand via the tray
1077+
icon; closing it returns the app to its tray-only state. The app
1078+
exits only via an explicit "Quit" command.
1079+
1080+
The tray icon **is** the entry point — it's the app's primary
1081+
top-level surface. The API treats it as a peer of `OpenWindow`:
1082+
1083+
```csharp
1084+
ReactorApp.ShutdownPolicy = ShutdownPolicy.Explicit;
1085+
1086+
ReactorApp.Run(ctx =>
1087+
{
1088+
var tray = ctx.OpenTrayIcon(new TrayIconSpec(
1089+
Key: "main",
1090+
Icon: WindowIcon.FromResource("Assets/tray.ico"),
1091+
Tooltip: "Sync Agent — idle"));
1092+
1093+
// Single-instance window keyed by "main". Opening it twice from
1094+
// a double-click reuses the existing window. Closing it removes
1095+
// the entry from ReactorApp.Windows; the next click reopens.
1096+
void ToggleMainWindow()
1097+
{
1098+
if (ReactorApp.FindWindow("main") is { } existing)
1099+
{
1100+
existing.Activate();
1101+
return;
1102+
}
1103+
1104+
ReactorApp.OpenWindow(
1105+
new WindowSpec(
1106+
Key: "main",
1107+
Title: "Sync Agent",
1108+
Width: 720, Height: 520,
1109+
StartPosition: WindowStartPosition.RestoreFromPersistence,
1110+
PersistenceId: "main"),
1111+
() => new SyncAgentShell());
1112+
}
1113+
1114+
tray.Click += (_, _) => ToggleMainWindow();
1115+
tray.RightClick += (_, _) => tray.ShowFlyout(BuildContextMenu());
1116+
1117+
Element BuildContextMenu() =>
1118+
VStack(
1119+
Button("Open", () => { ToggleMainWindow(); tray.HideFlyout(); }),
1120+
Button("Pause sync", () => SyncService.Pause()),
1121+
Separator(),
1122+
Button("Quit", () => ReactorApp.Exit()));
1123+
});
1124+
```
1125+
1126+
Key behaviors this exercises:
1127+
1128+
- The startup callback opens **only a tray icon, no window** — the
1129+
message loop runs because `ShutdownPolicy.Explicit` doesn't gate
1130+
on surfaces.
1131+
- `OpenTrayIcon` and `OpenWindow` share the same shape. A reader
1132+
who knows one knows the other.
1133+
- The tray icon's right-click flyout content is a Reactor `Element`
1134+
reconciled into the hidden flyout window.
1135+
- Closing the main window does **not** exit the app — the tray icon
1136+
is still open. `ReactorApp.Exit()` is the only path that ends the
1137+
process under this policy.
1138+
- A second click of the tray icon while the window is already open
1139+
calls `Activate()` on the existing window rather than spawning a
1140+
new one — `WindowKey` semantics fall out naturally because we
1141+
used `FindWindow("main")` before `OpenWindow`.
1142+
1143+
The complementary pattern — start with a window visible, fall back
1144+
to tray-only when the user closes it — uses
1145+
`ShutdownPolicy.Explicit` plus a tray icon as the persistent
1146+
surface:
1147+
1148+
```csharp
1149+
ReactorApp.Run(ctx =>
1150+
{
1151+
ReactorApp.ShutdownPolicy = ShutdownPolicy.Explicit;
1152+
1153+
var tray = ctx.OpenTrayIcon(new TrayIconSpec(/**/));
1154+
tray.Click += (_, _) => /* show / hide window */;
1155+
1156+
ctx.OpenWindow(
1157+
new WindowSpec(Key: "main", Title: "Chat", Width: 480, Height: 720),
1158+
() => new ChatShell());
1159+
});
1160+
```
1161+
9641162
## §14 Implementation plan
9651163

9661164
The work splits into ~6 phased PRs so each lands behind a clearly tested
@@ -975,7 +1173,7 @@ seam.
9751173
| **5. Persistence + chrome** | `PersistenceId`, `IWindowPersistenceStore`, `Icon`, `Presenter`, `IsResizable` / `IsMinimizable` / `IsMaximizable`, min/max via `WM_GETMINMAXINFO` hook. | Unit test for persistence round-trip with monitor-fingerprint mismatch. |
9761174
| **6. Devtools / MCP** | `WindowRegistry` integration (open/close events), MCP tools `windows.list` / `windows.activate` / `windows.close` / `windows.open`. | MCP tool tests; `mur devtools` golden flow with two windows. |
9771175
| **7. Shell integration — progress + overlay + thumbnail toolbar** | `ITaskbarList3` wrapper (lazy COM init); `ReactorWindow.Progress`, `ReactorWindow.Overlay`, `SetThumbnailToolbar`. | Selftest fixtures verifying property writes don't throw on Windows 10 / Windows 11; AppTest E2E for progress visibility via UIA. |
978-
| **8. Shell integration — jump list + tray + activation** | `JumpList` static, packaged + unpackaged paths, `ReactorWindow.RegisterTrayIcon`, `UseTrayIcon` hook, `LaunchActivation` plumbing. | Selftest for tray flyout reconciliation; E2E for jump list registration round-trip via `Reactor.Cli`. |
1176+
| **8. Shell integration — jump list + tray + activation** | `JumpList` static, packaged + unpackaged paths, `ReactorTrayIcon` + `ReactorApp.OpenTrayIcon` (peer of `ReactorWindow`), `UseTrayIcon` hook, `LaunchActivation` plumbing. | Selftest for tray flyout reconciliation, tray-only startup with `ShutdownPolicy.Explicit`; E2E for jump list registration round-trip via `Reactor.Cli`. |
9791177

9801178
Each phase ships independently. Phases 4–6 are gated by 1–3. Phases 7–8
9811179
are gated by 1 (they need `ReactorWindow`) but otherwise stand alone
@@ -994,13 +1192,15 @@ stays with the spec.
9941192
follow-up. `ContentDialog` covers the common in-window modal case
9951193
in the meantime.
9961194

997-
2. **Tray icons.** *In scope.* Tray support is one of the top
998-
requested features; it ships as part of phase 8 (§14) and lives in
999-
core Reactor under `ReactorWindow.RegisterTrayIcon` /
1000-
`UseTrayIcon`. No separate `Reactor.Tray` package — keeping it in
1001-
core matches developer expectations and avoids splitting the
1002-
shell-integration surface across two assemblies. See §11.4 for the
1003-
full API.
1195+
2. **Tray icons.** *In scope, modeled as a peer of `ReactorWindow`.*
1196+
`ReactorApp.OpenTrayIcon` is shaped exactly like
1197+
`ReactorApp.OpenWindow`, the returned `ReactorTrayIcon` mirrors
1198+
`ReactorWindow`'s handle shape, and `ReactorApp.TrayIcons` parallels
1199+
`Windows`. A tray icon and a window can both be the app's user-
1200+
facing entry point (see §13.6 for tray-only startup), so they
1201+
share lifecycle and naming conventions. Ships in phase 8 (§14)
1202+
and lives in core Reactor — no separate `Reactor.Tray` package.
1203+
See §11.4 for the full API.
10041204

10051205
3. **Multi-instance / single-instance app pattern.** *Deferred.*
10061206
Reactor v1 stays neutral on AppInstance redirection. `WindowKey`'s

0 commit comments

Comments
 (0)