Merge branch 'dev' into canary
This commit is contained in:
@@ -15,12 +15,12 @@ final class LiveActivityManager {
|
||||
/// still summarize everything, this just bounds the per-session list.
|
||||
private static let maxLines = 3
|
||||
|
||||
/// How long the terminal "all clear / Done" glance lingers before the Activity dismisses. Long
|
||||
/// enough to read at a glance, short enough not to loiter on the lock screen.
|
||||
private static let doneLinger: Duration = .seconds(4)
|
||||
/// The delayed dismissal after the Done glance is shown, cancelled if work resumes first — so a
|
||||
/// completed run flashes "Done" and then clears instead of vanishing the instant it finishes.
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
/// Whether the glance is currently holding the terminal "Done" summary. When work finishes while
|
||||
/// the app is away, the Activity is kept on the lock screen showing the finished sessions and
|
||||
/// *held there* — not dismissed on a timer — until the user opens the app and sees them, so a
|
||||
/// completed run isn't dropped after a few seconds unseen (UX_IOS §5.3). Set when the Done glance
|
||||
/// goes up; cleared when work resumes, or when the app foregrounds and the glance ends.
|
||||
private var showingDoneGlance = false
|
||||
|
||||
private var activity: Activity<NucleicSessionAttributes>?
|
||||
|
||||
@@ -34,7 +34,14 @@ final class LiveActivityManager {
|
||||
/// itself carries an `AlertConfiguration` (sound/haptic). Starts `true` — `onAppear` runs
|
||||
/// foreground; the background adopt path flips it. Mirrors the host's connected-vs-away gate for
|
||||
/// the pushed glance, but for the still-connected phone whose own socket is alive.
|
||||
var foreground = true
|
||||
var foreground = true {
|
||||
didSet {
|
||||
// The app just came forward and the user can now see the in-app session list — so dismiss
|
||||
// the "Done" glance we were holding on the lock screen for exactly this moment. The
|
||||
// counterpart to `finishWithDoneGlance` keeping it up while the app was away.
|
||||
if foreground, !oldValue, showingDoneGlance { end() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The last content we pushed. Updates that don't change it are skipped so we don't spend
|
||||
/// ActivityKit's update budget on no-ops — `sync` fires on every host message (dashboard,
|
||||
@@ -91,10 +98,9 @@ final class LiveActivityManager {
|
||||
finishWithDoneGlance(hostName: hostName, live: live)
|
||||
return
|
||||
}
|
||||
// Work is active again — abort any pending "Done" dismissal so the glance doesn't clear
|
||||
// out from under a run that just resumed (or a new one that just started).
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
// Work is active again — drop any held "Done" glance so the fresh active state renders
|
||||
// instead of the finished summary (a run just resumed, or a new one started).
|
||||
showingDoneGlance = false
|
||||
|
||||
// Everything in flight or waiting on the user, attention-first (approvals, then waiting
|
||||
// input, then running), freshest within a rank. This is both the detail-row source and
|
||||
@@ -124,17 +130,22 @@ final class LiveActivityManager {
|
||||
push(state, hostName: hostName)
|
||||
}
|
||||
|
||||
/// Nothing is running or waiting. If the glance was showing active work, flash a brief "Done"
|
||||
/// summary of the just-completed sessions and then dismiss (UX_IOS §5.3 — a run should read as
|
||||
/// *finished*, not just vanish). If nothing was on screen, there's nothing to close.
|
||||
/// Nothing is running or waiting. If the glance was showing active work, replace it with a "Done"
|
||||
/// summary of the just-completed sessions and hold it on the lock screen until the user opens the
|
||||
/// app and sees them (UX_IOS §5.3 — a finished run should read as *done* and stay put, not vanish
|
||||
/// after a few seconds unseen). Foreground, the user is already on the in-app session list, so
|
||||
/// there's nothing to hold — dismiss. If nothing was on screen, there's nothing to close.
|
||||
private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
|
||||
// Already flashing "Done" and scheduled to dismiss — let that run rather than restart it.
|
||||
guard dismissTask == nil else { return }
|
||||
// Already holding the "Done" summary — leave it up until the app foregrounds, don't rebuild it.
|
||||
guard !showingDoneGlance else { return }
|
||||
// Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering.
|
||||
guard activity != nil else {
|
||||
if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() }
|
||||
return
|
||||
}
|
||||
// Foreground: the user is already looking at the in-app session list (the glance isn't even
|
||||
// visible over the app), so there's nothing to hold for later — just dismiss.
|
||||
guard !foreground else { end(); return }
|
||||
// The sessions that just finished — completed conversational turns and finished runs. These
|
||||
// are exactly the ones the active-work filter above drops, surfaced now as `.done` rows.
|
||||
let done = live
|
||||
@@ -145,18 +156,16 @@ final class LiveActivityManager {
|
||||
.sorted(by: StatusStyle.attentionThenRecency)
|
||||
.prefix(Self.maxLines)
|
||||
.map(Self.line(for:))
|
||||
// Nothing to celebrate (e.g. the work was discarded/deleted) → just dismiss.
|
||||
// Nothing to show (e.g. the work was discarded/deleted) → just dismiss.
|
||||
guard !done.isEmpty else { end(); return }
|
||||
|
||||
let state = NucleicSessionAttributes.ContentState(
|
||||
runningCount: 0, needsYouCount: 0, approvalCount: 0,
|
||||
filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
|
||||
push(state, hostName: hostName)
|
||||
dismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.doneLinger)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.end()
|
||||
}
|
||||
// Hold it: the Activity stays alive showing "Done". No scheduled dismissal — `foreground`
|
||||
// flipping true (the app coming forward) is what ends it.
|
||||
showingDoneGlance = true
|
||||
}
|
||||
|
||||
/// Apply `state` to the Activity — coalesced through one serial task so the newest state always
|
||||
@@ -318,8 +327,7 @@ final class LiveActivityManager {
|
||||
updateTask = nil
|
||||
tokenObservation?.cancel()
|
||||
tokenObservation = nil
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
showingDoneGlance = false
|
||||
pendingState = nil
|
||||
pendingAlert = nil
|
||||
lastState = nil
|
||||
|
||||
@@ -159,23 +159,13 @@ struct SessionDetailView: View {
|
||||
return min(100, Int((Double(used) / Double(window)) * 100))
|
||||
}
|
||||
|
||||
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
|
||||
/// session header, folded into the glass chat bar so they don't cost a strip of transcript
|
||||
/// height. Reads current state from the summary; each change is a control intent.
|
||||
/// The context-occupancy readout and the Auto toggle for the open session, folded into the
|
||||
/// glass chat bar so they don't cost a strip of transcript height. The model / effort
|
||||
/// selectors used to sit here too, but they moved into the ⋯ menu (`modelEffortMenu`) to keep
|
||||
/// the bar compact. Reads current state from the summary; each change is a control intent.
|
||||
@ViewBuilder
|
||||
private func controlRow(_ summary: WireSessionSummary) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
||||
// No explicit model yet → use the session backend's default, so the menu
|
||||
// shows that backend's effort range rather than collapsing to one level.
|
||||
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
|
||||
controlled: controlled,
|
||||
// Orchestra is a one-way latch for an existing chat — the host won't turn it
|
||||
// off, so disable the other levels here to match.
|
||||
lockWhenOrchestra: true)
|
||||
Spacer(minLength: 4)
|
||||
if let percent = contextPercent {
|
||||
// Weight and primary ink (not `.secondary`) so the metric stays legible over
|
||||
// whatever the Liquid Glass bar is blurring through.
|
||||
@@ -183,6 +173,7 @@ struct SessionDetailView: View {
|
||||
.font(.caption2.weight(.semibold)).foregroundStyle(.primary)
|
||||
.help("Context window used")
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
// Locked on for a Nucleic Control project — those chats always run autonomously, and
|
||||
// the host keeps Auto on regardless of what the phone sends, so show it locked on to
|
||||
// match rather than offering a toggle that wouldn't take.
|
||||
@@ -198,7 +189,8 @@ struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions.
|
||||
/// The ⋯ menu: the diff viewer for everyone, then — at control scope — the next-turn model /
|
||||
/// effort selectors (moved off the composer to keep it compact) and the session actions.
|
||||
@ViewBuilder
|
||||
private var sessionMenu: some View {
|
||||
Menu {
|
||||
@@ -206,6 +198,8 @@ struct SessionDetailView: View {
|
||||
Label(diffLabel, systemImage: "plus.forwardslash.minus")
|
||||
}
|
||||
if let summary, store.canControl {
|
||||
Divider()
|
||||
modelEffortMenu(summary)
|
||||
Divider()
|
||||
sessionActions(summary)
|
||||
}
|
||||
@@ -214,6 +208,23 @@ struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The model and effort selectors for the next turn, relocated from the composer's control row
|
||||
/// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels.
|
||||
/// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here.
|
||||
@ViewBuilder
|
||||
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
|
||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
||||
// No explicit model yet → use the session backend's default, so the menu
|
||||
// shows that backend's effort range rather than collapsing to one level.
|
||||
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
|
||||
controlled: controlled,
|
||||
// Orchestra is a one-way latch for an existing chat — the host won't turn it
|
||||
// off, so disable the other levels here to match.
|
||||
lockWhenOrchestra: true)
|
||||
}
|
||||
|
||||
/// "View changes" with the live +/− counts when the host projects them, so the menu hints
|
||||
/// whether there's anything to look at before opening the sheet.
|
||||
private var diffLabel: String {
|
||||
|
||||
Reference in New Issue
Block a user