Merge nucleic/frosty-feather-heron into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user