@@ -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
171171ReactorWindow 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.
348365public 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
355375public 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.
477514uint RenderContext .UseDpi ();
478515
479516// Window state; re-renders on minimize/maximize/restore/etc.
517+ // Returns Normal when called outside a window.
480518WindowState RenderContext .UseWindowState ();
481519
482520// Activation; re-renders on activated/deactivated.
521+ // Returns true when called outside a window (the flyout is "active" while shown).
483522bool 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.
488528void RenderContext .UseClosingGuard (Func < bool > canClose );
@@ -500,6 +540,38 @@ The mirror methods on `Component` (currently `UseWindowSize(Window)` /
500540overloads. The explicit ` Window ` -typed overloads stay for back compat
501541and 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
687781public 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
716821A common UX pattern is "minimize to tray". This is built on the
717822public 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
9491057ReactorApp .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
9661164The 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
9801178Each phase ships independently. Phases 4–6 are gated by 1–3. Phases 7–8
9811179are 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
100512053 . ** Multi-instance / single-instance app pattern.** * Deferred.*
10061206 Reactor v1 stays neutral on AppInstance redirection. ` WindowKey ` 's
0 commit comments