Skip to content

Commit 5ceed90

Browse files
committed
Merge U2: compose/reply/send, undo, Sync-now button, Summarize chip in the app
2 parents 404f24b + 047c75a commit 5ceed90

19 files changed

Lines changed: 2228 additions & 36 deletions

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ let package = Package(
4141
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine"]),
4242
.target(
4343
name: "HudsonUI",
44-
dependencies: ["GmailKit", "Store", "SyncEngine",
44+
dependencies: ["GmailKit", "Store", "SyncEngine", "Outbox", "AIKit",
4545
.product(name: "GRDB", package: "GRDB.swift")],
4646
resources: [.process("Resources")]
4747
),
4848
.executableTarget(name: "HudsonApp", dependencies: ["HudsonUI"]),
49-
.testTarget(name: "HudsonUITests", dependencies: ["HudsonUI", "Store"]),
49+
.testTarget(name: "HudsonUITests", dependencies: ["HudsonUI", "Store", "AIKit"]),
5050
// MIME building + send state machine (spec §7). Depends on GmailKit
5151
// for the `SentMessage`/`SendTransport` shapes SendService sends
5252
// through, and Store for the `send_jobs` durable queue it persists to.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import AIKit
2+
import Foundation
3+
import GmailKit
4+
import Store
5+
6+
/// Builds a `Summarize` from whatever `ai_config` + Keychain hold for
7+
/// `account` — the UI-side seam `SummaryModel` runs the Summarize chip
8+
/// through, mirroring `SendBootstrap`/`SyncBootstrap`'s "Keychain → engine"
9+
/// shape. Fail-closed by construction: if the feature isn't opted in this
10+
/// returns `nil` and no provider is ever built, so the chip degrades to a
11+
/// "turn AI on" banner instead of egressing.
12+
///
13+
/// This duplicates a handful of lines from `HudsonCLI`'s `AIRuntime.bootstrap`
14+
/// rather than sharing them: `HudsonUI` cannot depend on `HudsonCLI`
15+
/// (dependencies only run executable → library, never the reverse) — the same
16+
/// reason `SyncBootstrap`/`SendBootstrap` re-derive their own `GmailClient`
17+
/// wiring. `hudson ai config` (CLI) stays the SOLE writer of `ai_config`, and
18+
/// the source of truth for the `base_url` provider encoding decoded below.
19+
enum AIBootstrap {
20+
/// Reads `summarize`'s `ai_config` row and, only if it is opted in,
21+
/// assembles the provider (`base_url` names which — see
22+
/// `SummarizeProviderSelection`), the stored API key
23+
/// (`KeychainLLMKeyStore`, keyed by provider kind), an `EgressGuard` over
24+
/// that provider, and the `Summarize` on top. Returns `nil` when the
25+
/// feature has no row or `opt_in != true` — the fail-closed default that
26+
/// matches `EgressGuard`'s own gate, so a not-opted-in feature can't even
27+
/// reach a built provider.
28+
///
29+
/// `keyStore`/`http` default to the real Keychain/URLSession seams in
30+
/// production but are injectable so tests exercise the opt-in gating
31+
/// without touching the macOS Keychain or the network (spec §6.3, the
32+
/// same rule every other `LLMKeyStore` consumer follows).
33+
static func makeSummarize(
34+
database: HudsonDatabase,
35+
account: String,
36+
keyStore: any LLMKeyStore = KeychainLLMKeyStore(),
37+
http: any LLMHTTP = URLSessionLLMHTTP()
38+
) async -> Summarize? {
39+
// Fail closed on ANY read failure too, not just a missing/opt-out row:
40+
// a Store error must never be the reason content leaks, so `try?` +
41+
// the `optIn` guard both have to hold before a provider is built.
42+
guard
43+
let config = try? await database.aiConfig(
44+
feature: AIFeature.summarize.rawValue, account: account),
45+
config.optIn
46+
else { return nil }
47+
48+
let selection = SummarizeProviderSelection.decode(config.baseURL)
49+
// A keyless local provider (Ollama/LM Studio) is valid: an absent
50+
// Keychain entry degrades to "", never a thrown error — matching
51+
// `AIRuntime.bootstrap`'s "tolerate an empty key" contract.
52+
let apiKey = (try? keyStore.key(provider: selection.keychainProvider)) ?? ""
53+
let provider = selection.buildProvider(http: http, apiKey: apiKey)
54+
let egressGuard = EgressGuard(provider: provider, database: database, account: account)
55+
return Summarize(guard: egressGuard, database: database, account: account)
56+
}
57+
}
58+
59+
/// Which LLM backend a stored `ai_config` row selects, decoded from the
60+
/// `base_url` column. No AIKit feature reads `base_url`, so `hudson ai config`
61+
/// repurposes it as an opaque `"<kind>"` / `"<kind>|<url>"` encoding of
62+
/// "which provider, and any base-URL override" (see `AICommands`'
63+
/// `AIProviderConfig`, the encoder). HudsonUI can't import HudsonCLI to reuse
64+
/// that decoder (see `AIBootstrap`'s doc comment), so the read side is
65+
/// mirrored here — deliberately lenient: anything unrecognized or hand-edited
66+
/// falls back to `.anthropic` with no override, an inert default that can't
67+
/// itself cause egress (the opt-in gate, not this decode, is what blocks it).
68+
private enum SummarizeProviderSelection {
69+
case anthropic
70+
case openAICompat(baseURL: URL?)
71+
72+
private static let separator: Character = "|"
73+
74+
static func decode(_ raw: String?) -> SummarizeProviderSelection {
75+
guard let raw, !raw.isEmpty else { return .anthropic }
76+
let parts = raw.split(separator: Self.separator, maxSplits: 1)
77+
guard let kind = parts.first else { return .anthropic }
78+
switch String(kind) {
79+
case "openai-compat":
80+
let override = parts.count > 1 ? URL(string: String(parts[1])) : nil
81+
return .openAICompat(baseURL: override)
82+
default:
83+
// "anthropic" or any stale/hand-edited value — inert default.
84+
return .anthropic
85+
}
86+
}
87+
88+
/// The `provider` key `KeychainLLMKeyStore` filed this backend's API key
89+
/// under (`ai config` writes it keyed by this same raw value).
90+
var keychainProvider: String {
91+
switch self {
92+
case .anthropic: return "anthropic"
93+
case .openAICompat: return "openai-compat"
94+
}
95+
}
96+
97+
/// Builds the live provider. Construction is pure (no I/O — see the
98+
/// providers' own doc comments), so building one for a feature that turns
99+
/// out not to egress is harmless. `.anthropic` never honors a base-URL
100+
/// override (there is none to pass); `.openAICompat` uses the configured
101+
/// URL when present, else the provider's OpenAI default.
102+
func buildProvider(http: any LLMHTTP, apiKey: String) -> any LLMProvider {
103+
switch self {
104+
case .anthropic:
105+
return AnthropicProvider(http: http, apiKey: apiKey)
106+
case .openAICompat(let baseURL):
107+
guard let baseURL else { return OpenAICompatProvider(http: http, apiKey: apiKey) }
108+
return OpenAICompatProvider(http: http, apiKey: apiKey, baseURL: baseURL)
109+
}
110+
}
111+
}

Sources/HudsonUI/Model/AppModel.swift

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)