Merge nucleic/frosty-feather-heron into dev

This commit is contained in:
2026-06-15 02:22:12 -07:00
parent bf55974165
commit dbf6814ebf
6 changed files with 112 additions and 0 deletions
+12
View File
@@ -442,6 +442,9 @@ struct SessionRow: View {
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
}
Text(summary.title).lineLimit(1)
// A completed chat the user hasn't opened reads as "unread"
// emphasize its title alongside the trailing dot below.
.fontWeight(summary.unseenCompletion ? .semibold : .regular)
}
HStack(spacing: 4) {
Text(summary.status.label(disposition: summary.disposition))
@@ -463,6 +466,15 @@ struct SessionRow: View {
Text("+\(diff.added) \(diff.removed)")
.font(.caption2.monospaced()).foregroundStyle(.secondary)
}
if summary.unseenCompletion {
// Unread dot: the chat finished its work and the user hasn't opened it
// yet. Cleared the moment they do. White on the selection highlight so
// it stays legible (the row can't be both selected and unread for long,
// but selection can land before the open-driven clear).
Circle().fill(isSelected ? Color.white : palette.accent)
.frame(width: 7, height: 7)
.accessibilityLabel("Unread — completed")
}
}
.padding(.vertical, 5)
// A small leading inset keeps the status dot off the selection highlight's
+28
View File
@@ -34,6 +34,9 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
public var auto: Bool
public var favorite: Bool
public var archived: Bool
/// The chat finished its work but the user hasn't opened it since drives the
/// sidebar's unread dot. Only meaningful together with `isCompleted`.
public var unseenCompletion: Bool
public var createdAt: Date
public var updatedAt: Date
@@ -48,6 +51,7 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
self.auto = session.auto
self.favorite = session.favorite
self.archived = session.archived
self.unseenCompletion = session.unseenCompletion
self.createdAt = session.createdAt
self.updatedAt = session.updatedAt
}
@@ -1674,6 +1678,9 @@ public final class AppStore: ConflictArbiter {
if case .runFinished = event.kind { classifyDisposition(sessionID, controller) }
let snapshot = await controller.snapshot
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
// A finished run (or a fresh turn) flips whether the chat is completed; refresh the
// unread dot now. Disposition-classified completion is handled in classifyDisposition.
await reconcileUnseenCompletion(sessionID, snapshot)
// Project to any connected iPhone (SYNC §5): the event itself, the dedicated
// approval push for approval kinds, and the updated session summary.
@@ -1717,6 +1724,8 @@ public final class AppStore: ConflictArbiter {
private func reloadOpen() async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
let snapshot = await controller.snapshot
// Opening a chat marks it seen: clear any "completed but unopened" unread dot.
await reconcileUnseenCompletion(sessionID, snapshot)
let events = await controller.transcriptSoFar()
applyOpenTranscript(events, for: sessionID)
if sessionID == openSessionID {
@@ -1865,6 +1874,9 @@ public final class AppStore: ConflictArbiter {
self.upsertSummary(SessionSummary(
snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
if sessionID == self.openSessionID { self.openSession = snapshot.session }
// The turn was just classified done/awaiting refresh the unread dot, which
// for conversational chats is driven by this disposition rather than `.finished`.
await self.reconcileUnseenCompletion(sessionID, snapshot)
if disposition == .completed {
await self.releaseSandboxIfCompleted(sessionID)
await self.shipIfCompleted(sessionID)
@@ -1934,6 +1946,22 @@ public final class AppStore: ConflictArbiter {
openTranscript = events
}
/// Reconcile a session's "completed but not yet opened" flag so the sidebar shows an
/// unread dot on a chat that finished its work while the user was elsewhere. The flag
/// is true exactly when the chat is completed and isn't the one currently open; opening
/// it (it becomes `openSessionID`) or starting a new turn (no longer completed) clears it.
/// A no-op when nothing changed, so it's cheap to call on every ingested event.
private func reconcileUnseenCompletion(_ sessionID: SessionID, _ snapshot: SessionController.Snapshot) async {
guard let controller = controllers[sessionID] else { return }
let summary = SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count)
let unseen = summary.isCompleted && sessionID != openSessionID
guard snapshot.session.unseenCompletion != unseen else { return }
await controller.setUnseenCompletion(unseen)
let updated = await controller.snapshot
upsertSummary(SessionSummary(updated.session, pendingApprovalCount: updated.pendingApprovals.count))
if sessionID == openSessionID { openSession = updated.session }
}
private func upsertSummary(_ summary: SessionSummary) {
if let index = summaries.firstIndex(where: { $0.id == summary.id }) {
summaries[index] = summary
@@ -122,6 +122,9 @@ public final class GRDBMetadataStore: SessionMetadataStore {
migrator.registerMigration("v10-autoship") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto_ship INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v11-unseen-completion") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN unseen_completion INTEGER NOT NULL DEFAULT 0;")
}
return migrator
}
@@ -309,6 +312,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
var auto_ship: Bool
var favorite: Bool
var archived: Bool
var unseen_completion: Bool
var summary: String?
var last_turn_disposition: String?
var created_at: Date
@@ -338,6 +342,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
auto_ship = s.autoShip
favorite = s.favorite
archived = s.archived
unseen_completion = s.unseenCompletion
summary = s.summary
last_turn_disposition = s.lastTurnDisposition?.rawValue
created_at = s.createdAt
@@ -370,6 +375,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
autoShip: auto_ship,
favorite: favorite,
archived: archived,
unseenCompletion: unseen_completion,
summary: summary,
lastTurnDisposition: last_turn_disposition.flatMap(TurnDisposition.init(rawValue:)),
createdAt: created_at,
+6
View File
@@ -73,6 +73,10 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
public var autoShip: Bool
public var favorite: Bool
public var archived: Bool
/// The chat reached a completed state (see `SessionSummary.isCompleted`) that the
/// local user hasn't opened yet drives the sidebar's "unread" dot. Set when a
/// turn finishes while the chat isn't the open one; cleared when the user opens it.
public var unseenCompletion: Bool
/// Cached model-generated summary so it isn't regenerated on every open.
public var summary: String?
/// How the last turn ended (asking vs. done), refining `.awaitingInput` for the
@@ -104,6 +108,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
autoShip: Bool = false,
favorite: Bool = false,
archived: Bool = false,
unseenCompletion: Bool = false,
summary: String? = nil,
lastTurnDisposition: TurnDisposition? = nil,
createdAt: Date,
@@ -130,6 +135,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
self.autoShip = autoShip
self.favorite = favorite
self.archived = archived
self.unseenCompletion = unseenCompletion
self.summary = summary
self.lastTurnDisposition = lastTurnDisposition
self.createdAt = createdAt
@@ -539,6 +539,16 @@ public actor SessionController {
try? await metadataStore?.saveSession(session)
}
/// Mark (or clear) the "completed but not yet opened" flag that drives the sidebar's
/// unread dot. Set by the store when a turn completes off-screen; cleared on open.
public func setUnseenCompletion(_ unseen: Bool) async {
guard session.unseenCompletion != unseen else { return }
session.unseenCompletion = unseen
// Deliberately does not bump `updatedAt`: an unread marker is a view-state
// change, and opening a chat to clear it must not reorder the sidebar.
try? await metadataStore?.saveSession(session)
}
public func setFavorite(_ favorite: Bool) async {
session.favorite = favorite
session.updatedAt = now()
@@ -813,6 +813,56 @@ struct AppStoreTests {
#expect(store2.summaries.first { $0.id == sessionID }?.disposition == .completed)
}
@Test func unseenCompletionFlagsCompletedChatsUntilOpened() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
// Every chat ends by reporting it finished the work, so the heuristic classifier
// lands the turn on `.completed` (an "unread, done" chat for the sidebar).
let store = AppStore(
database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }
) { session in
ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
e.emit(.assistantText(TextChunk(messageID: "a",
text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
// A chat that completes while the user is looking elsewhere becomes "unread".
let background = try await store.createSession(in: project, title: "bg", prompt: "go")
await waitFor({
store.summaries.first { $0.id == background }?.unseenCompletion == true
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == background }?.disposition == .completed)
#expect(store.summaries.first { $0.id == background }?.unseenCompletion == true)
// Opening it marks it seen the dot clears.
store.openSessionID = background
await store.awaitOpenSessionSettled()
await waitFor({
store.summaries.first { $0.id == background }?.unseenCompletion == false
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == background }?.unseenCompletion == false)
// A chat that completes while it's the open one was never unread.
let foreground = try await store.createSession(in: project, title: "fg", prompt: "go")
store.openSessionID = foreground
await store.awaitOpenSessionSettled()
await waitFor({
store.summaries.first { $0.id == foreground }?.disposition == .completed
}, timeoutMs: 8000)
#expect(store.summaries.first { $0.id == foreground }?.unseenCompletion == false)
}
@Test func classifiesTurnDispositionForSidebar() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }