@@ -3,8 +3,9 @@ import Store
33
44/// The root of the app's object graph. Owns the open database, the active
55/// account, and every child view model (`inbox`/`thread`/`command`/
6- /// `search`), plus the app-level presentation state (which overlay is
7- /// showing) and keyboard routing (`apply(_:)`, driven by `KeyboardMonitor`).
6+ /// `search`/`composer`), plus the app-level presentation state (which
7+ /// overlay is showing) and keyboard routing (`apply(_:)`, driven by
8+ /// `KeyboardMonitor`).
89/// `@MainActor` because every view model in Hudson is main-actor — SwiftUI
910/// reads them on the main thread and Store access is via async APIs, so
1011/// nothing here ever blocks a cooperative-pool thread.
@@ -18,6 +19,13 @@ public final class AppModel {
1819 public let thread : ThreadModel
1920 public let command : CommandModel
2021 public let search : SearchModel
22+ public let composer : ComposerModel
23+
24+ /// The Summarize chip's view model for the OPEN thread. Reset on every
25+ /// `openThread` so it never carries one thread's summary over to another,
26+ /// and driven ONLY by `summarizeOpenThread()` — the explicit tap. Per
27+ /// Privacy #1 it never runs on its own (no summarize-on-open).
28+ public let summary : SummaryModel
2129
2230 /// The sidebar's "LABELS" section — a one-shot read at launch (Store has
2331 /// no `observeLabels` twin the way inbox rows/split rules do, and this
@@ -41,6 +49,12 @@ public final class AppModel {
4149 public var isPaletteVisible = false
4250 /// Whether the ⌘F/`/` search overlay is showing.
4351 public var isSearchVisible = false
52+ /// Whether the compose sheet (`ComposerView`, bound to `composer`) is
53+ /// showing — opened by `composeNew()`/`replyToOpenThread()`, and closed
54+ /// either by the sheet's own Cancel/Esc (`RootView` sets this back to
55+ /// `false` directly) or by a SUCCESSFUL send, via `composer.onClose`
56+ /// (wired in both initializers below) — see `wireComposerDismissal`.
57+ public var isComposerVisible = false
4458
4559 /// A user-visible strip for app-level sync state (offline, no account
4660 /// connected, a failed pass) — `nil` when there's nothing to show.
@@ -66,10 +80,13 @@ public final class AppModel {
6680 self . thread = ThreadModel ( database: database, account: email)
6781 self . command = CommandModel ( )
6882 self . search = SearchModel ( database: database, account: email)
83+ self . composer = ComposerModel ( database: database, account: account)
84+ self . summary = SummaryModel ( database: database, account: email)
6985 await inbox. start ( )
7086 await refreshLabels ( )
7187 subscribeToPendingCount ( )
7288 subscribeToUnreadCount ( )
89+ wireComposerDismissal ( )
7390 }
7491
7592 /// Direct-injection initializer for tests and previews (seeded
@@ -87,11 +104,14 @@ public final class AppModel {
87104 self . thread = ThreadModel ( database: database, account: email)
88105 self . command = CommandModel ( )
89106 self . search = SearchModel ( database: database, account: email)
107+ self . composer = ComposerModel ( database: database, account: account)
108+ self . summary = SummaryModel ( database: database, account: email)
90109 let inbox = self . inbox
91110 Task { await inbox. start ( ) }
92111 Task { [ weak self] in await self ? . refreshLabels ( ) }
93112 subscribeToPendingCount ( )
94113 subscribeToUnreadCount ( )
114+ wireComposerDismissal ( )
95115 }
96116
97117 /// The demo mailbox's account — matches `DemoData.seed`'s default so
@@ -172,6 +192,25 @@ public final class AppModel {
172192 labels = ( try ? await database. labels ( account: account. email) ) ?? [ ]
173193 }
174194
195+ /// Lets a successful send dismiss the compose sheet from HERE, not from
196+ /// the view: `ComposerModel.send()` fires `onClose?()` synchronously
197+ /// right after enqueueing (see its doc comment), so wiring that straight
198+ /// to `isComposerVisible = false` is the same "model owns the dismissal"
199+ /// shape `togglePalette`/`toggleSearch` already use for the other two
200+ /// overlays. Called once, at the end of each initializer, after every
201+ /// stored property (including `composer` itself) has a value — matches
202+ /// `subscribeToPendingCount`/`subscribeToUnreadCount`'s own established
203+ /// "capture self weakly once fully initialized" convention, just for a
204+ /// callback assignment rather than a `Task`.
205+ ///
206+ /// The compose sheet closing does NOT drop the just-sent draft's undo
207+ /// affordance — `ComposerModel.justSentUndoJobID` deliberately outlives
208+ /// `onClose` firing, so `RootView` renders that toast independently of
209+ /// whether the sheet itself is still mounted (see `RootView.assembled`).
210+ private func wireComposerDismissal( ) {
211+ composer. onClose = { [ weak self] in self ? . isComposerVisible = false }
212+ }
213+
175214 // MARK: - Navigation
176215
177216 /// Selects `threadID` in the inbox list and loads it into the reading
@@ -181,9 +220,56 @@ public final class AppModel {
181220 /// first emission either (see its doc comment), so this doesn't need to.
182221 public func openThread( _ threadID: String ) {
183222 inbox. selectedThreadID = threadID
223+ // Drop the previous thread's summary so the chip resets to its
224+ // untapped state — a summary is per-thread and must never bleed across
225+ // a switch. This clears local state only; it never triggers a new
226+ // summarize (that stays an explicit tap — Privacy #1, no auto-run).
227+ summary. reset ( )
184228 Task { await thread. open ( threadID: threadID) }
185229 }
186230
231+ /// Runs the Summarize chip for whichever thread is open
232+ /// (`inbox.selectedThreadID`, the same id the reading pane shows) —
233+ /// `ThreadView`'s chip funnels through here. This is the explicit user
234+ /// action the `.summarize` `Invocation` stands for; it egresses ONLY if
235+ /// the feature is opted in (the gate lives under `SummaryModel` →
236+ /// `AIBootstrap`/`EgressGuard`). A no-op, defensively, when nothing is
237+ /// selected. Synchronous like the other chrome actions: `summarize` is
238+ /// async, so it runs in its own `Task`.
239+ public func summarizeOpenThread( ) {
240+ guard let threadID = inbox. selectedThreadID else { return }
241+ Task { [ weak self] in await self ? . summary. summarize ( threadID: threadID) }
242+ }
243+
244+ // MARK: - Compose / reply (see `ComposerModel`; ⌘N and the reply bar both funnel here)
245+
246+ /// Opens the compose sheet with a blank draft — the ⌘N shortcut
247+ /// (`KeyAction.composeNew`) funnels through here. Synchronous:
248+ /// `ComposerModel.startNew()` does no async work (see its doc comment),
249+ /// so there's nothing to await before showing the sheet.
250+ public func composeNew( ) {
251+ composer. startNew ( )
252+ isComposerVisible = true
253+ }
254+
255+ /// Opens the compose sheet pre-filled as a reply to whichever thread is
256+ /// currently open (`inbox.selectedThreadID` — the same id `openThread(_:)`
257+ /// sets and `ThreadView`'s reading pane is showing) — `ThreadView`'s
258+ /// Reply bar funnels through here. A no-op, defensively, if nothing is
259+ /// selected: `RootView` only mounts the Reply bar once a thread is open,
260+ /// but this doesn't trust that invariant rather than risk showing an
261+ /// untethered sheet. `ComposerModel.startReply` is async (it reads the
262+ /// thread to build the real threading scaffold via `ReplyBuilder`), so
263+ /// the sheet is shown only AFTER it resolves — the draft is already
264+ /// fully prefilled the instant it appears, no empty-to-populated flash.
265+ public func replyToOpenThread( ) {
266+ guard let threadID = inbox. selectedThreadID else { return }
267+ Task { [ weak self] in
268+ await self ? . composer. startReply ( threadID: threadID)
269+ self ? . isComposerVisible = true
270+ }
271+ }
272+
187273 // MARK: - Palette / search presentation
188274
189275 /// Opens the palette (reloading its command list for the CURRENT split
@@ -270,18 +356,15 @@ public final class AppModel {
270356 case . clearSelection: inbox. selectedThreadID = nil
271357 case . togglePalette: togglePalette ( )
272358 case . toggleSearch: toggleSearch ( )
359+ case . composeNew: composeNew ( )
273360 }
274361 }
275362
276363 // MARK: - Sync (the ONLY network path in the app — see Privacy #1: user-initiated, never automatic)
277364
278- /// TODO(sync-wire): nothing in this milestone's UI calls this yet — the
279- /// sidebar's settings gear is still a placeholder (Task 9's doc
280- /// comment), and per Privacy #1 sync must stay user-initiated, so it's
281- /// deliberately never called automatically (e.g. on launch) either. The
282- /// implementation below is real and tested at the "no account/no
283- /// credentials" guard (`AppModelTests`); a future task wires an actual
284- /// "Sync now" affordance to call it.
365+ /// Wired to the sidebar footer's "Sync now" button (`RootView` ->
366+ /// `SidebarView.onSyncNow`) — per Privacy #1 this stays the ONLY way
367+ /// sync ever runs; it is never called automatically (e.g. on launch).
285368 ///
286369 /// Best-effort and fully guarded: builds the network stack from the
287370 /// Keychain (mirrors `HudsonCLI/Runtime.bootstrap()`, via
0 commit comments