Show an icon on the sidebar when autoship fails/disables for a session

When autoship aborts on a merge conflict or merge error it turns itself off; the
chat's sidebar entry now shows an exclamation-triangle marker until the user
re-enables autoship. Unlike the shipped marker (lastEventWasAutoship), this is
sticky session state, not tied to the latest event — a failure needs attention
and shouldn't vanish the moment something else happens.

- Session + SessionSummary gain `autoShipFailed`; persisted via migration
  v13-autoship-failed.
- SessionController.markAutoShipFailed() turns autoship off and sets the marker
  atomically; the merge queue's conflict/failed paths now call it instead of a
  plain setAutoShip(false). setAutoShip(true) clears the marker (re-opt-in). A
  clean user toggle-off leaves it untouched.
- RootView.SessionRow renders the warning icon, taking precedence over the
  shipped icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-15 03:38:21 -07:00
co-authored by Claude Opus 4.8
parent 2aa41d3f09
commit 1ea002e894
6 changed files with 75 additions and 5 deletions
+10 -1
View File
@@ -459,7 +459,16 @@ struct SessionRow: View {
}
}
Spacer()
if summary.lastEventWasAutoship {
if summary.autoShipFailed {
// Autoship aborted on a conflict/error and turned itself off a sticky
// "needs attention" marker that persists until the user re-enables
// autoship. Takes precedence over the shipped marker below (a failure
// note is itself the latest autoship event, but the warning is what
// matters here).
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
.foregroundStyle(palette.attention)
.accessibilityLabel("Autoship failed — turned off")
} else if summary.lastEventWasAutoship {
// Autoship was the most recent thing to happen here (e.g. the branch
// shipped) and nothing's happened since a glanceable marker that this
// chat reached the merge queue. Cleared once a new turn lands. White on
+7 -2
View File
@@ -41,6 +41,10 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
/// with nothing since) drives the sidebar's autoship icon. See
/// `Session.lastEventWasAutoship`.
public var lastEventWasAutoship: Bool
/// Autoship disabled itself on a failure (conflict/error) and hasn't been
/// re-enabled drives the sidebar's "autoship needs attention" icon. See
/// `Session.autoShipFailed`.
public var autoShipFailed: Bool
public var createdAt: Date
public var updatedAt: Date
@@ -57,6 +61,7 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
self.archived = session.archived
self.unseenCompletion = session.unseenCompletion
self.lastEventWasAutoship = session.lastEventWasAutoship
self.autoShipFailed = session.autoShipFailed
self.createdAt = session.createdAt
self.updatedAt = session.updatedAt
}
@@ -1157,7 +1162,7 @@ public final class AppStore: ConflictArbiter {
icon: "checkmark.seal")
case .conflicted(let paths):
// Stop the loop: a conflict needs a human (or a fresh agent turn) to resolve.
await mutateSession(update.sessionID) { await $0.setAutoShip(false) }
await mutateSession(update.sessionID) { await $0.markAutoShipFailed() }
let who = await shipErrorLabel(update.sessionID)
let list = paths.prefix(3).joined(separator: ", ")
let more = paths.count > 3 ? "" : ""
@@ -1169,7 +1174,7 @@ public final class AppStore: ConflictArbiter {
+ "Branch left intact; autoship turned off for this chat."
lastErrorSessionID = update.sessionID
case .failed(let reason):
await mutateSession(update.sessionID) { await $0.setAutoShip(false) }
await mutateSession(update.sessionID) { await $0.markAutoShipFailed() }
let who = await shipErrorLabel(update.sessionID)
await controller?.note(
"\(NoteEvent.autoshipPrefix) failed — \(reason). Autoship turned off.",
@@ -129,6 +129,10 @@ public final class GRDBMetadataStore: SessionMetadataStore {
try db.execute(
sql: "ALTER TABLE session ADD COLUMN last_event_was_autoship INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v13-autoship-failed") { db in
try db.execute(
sql: "ALTER TABLE session ADD COLUMN auto_ship_failed INTEGER NOT NULL DEFAULT 0;")
}
return migrator
}
@@ -318,6 +322,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
var archived: Bool
var unseen_completion: Bool
var last_event_was_autoship: Bool
var auto_ship_failed: Bool
var summary: String?
var last_turn_disposition: String?
var created_at: Date
@@ -349,6 +354,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
archived = s.archived
unseen_completion = s.unseenCompletion
last_event_was_autoship = s.lastEventWasAutoship
auto_ship_failed = s.autoShipFailed
summary = s.summary
last_turn_disposition = s.lastTurnDisposition?.rawValue
created_at = s.createdAt
@@ -383,6 +389,7 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
archived: archived,
unseenCompletion: unseen_completion,
lastEventWasAutoship: last_event_was_autoship,
autoShipFailed: auto_ship_failed,
summary: summary,
lastTurnDisposition: last_turn_disposition.flatMap(TurnDisposition.init(rawValue:)),
createdAt: created_at,
+8
View File
@@ -82,6 +82,12 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
/// arrive; drives the sidebar's autoship icon. A later user message or agent turn
/// clears it. See `NoteEvent.isAutoship`.
public var lastEventWasAutoship: Bool
/// Whether autoship disabled itself on a failure (merge conflict or merge error)
/// and hasn't been re-enabled since drives a distinct "autoship needs attention"
/// sidebar icon. Sticky (unlike `lastEventWasAutoship`): set when the merge queue
/// aborts and turns autoship off, cleared when the user re-enables autoship. A
/// clean user-initiated toggle-off does *not* set it.
public var autoShipFailed: 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
@@ -115,6 +121,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
archived: Bool = false,
unseenCompletion: Bool = false,
lastEventWasAutoship: Bool = false,
autoShipFailed: Bool = false,
summary: String? = nil,
lastTurnDisposition: TurnDisposition? = nil,
createdAt: Date,
@@ -143,6 +150,7 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
self.archived = archived
self.unseenCompletion = unseenCompletion
self.lastEventWasAutoship = lastEventWasAutoship
self.autoShipFailed = autoShipFailed
self.summary = summary
self.lastTurnDisposition = lastTurnDisposition
self.createdAt = createdAt
+17 -2
View File
@@ -531,10 +531,25 @@ public actor SessionController {
}
/// Toggle autoship. Enabling it also enables `auto` (shipping implies autonomous
/// approval). Disabling leaves `auto` as-is.
/// approval). Disabling leaves `auto` as-is. Enabling clears any prior failure
/// marker the user is opting back in, so the "needs attention" icon goes away.
public func setAutoShip(_ autoShip: Bool) async {
session.autoShip = autoShip
if autoShip { session.auto = true }
if autoShip {
session.auto = true
session.autoShipFailed = false
}
session.updatedAt = now()
try? await metadataStore?.saveSession(session)
}
/// Record that autoship aborted on a failure (merge conflict or merge error): turn
/// autoship off and set the sticky failure marker that drives the sidebar's
/// "autoship needs attention" icon. Distinct from a clean user toggle-off, which
/// leaves the marker untouched. Cleared when the user re-enables autoship.
public func markAutoShipFailed() async {
session.autoShip = false
session.autoShipFailed = true
session.updatedAt = now()
try? await metadataStore?.saveSession(session)
}
@@ -221,6 +221,32 @@ struct SessionControllerTests {
await controller.shutdown()
}
@Test func autoshipFailureSetsStickyFlagClearedOnReenable() async throws {
// Drives the sidebar's "autoship needs attention" icon: a merge-queue abort
// turns autoship off and sets a sticky failure marker, and re-enabling autoship
// clears it. A clean user toggle-off must NOT set the marker.
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
let (controller, _, cleanup) = try makeController(backend: backend)
defer { cleanup() }
await controller.setAutoShip(true)
#expect(await controller.snapshot.session.autoShip == true)
#expect(await controller.snapshot.session.autoShipFailed == false)
// A clean toggle-off is not a failure.
await controller.setAutoShip(false)
#expect(await controller.snapshot.session.autoShipFailed == false)
// A merge-queue abort: autoship off + sticky failure marker.
await controller.markAutoShipFailed()
#expect(await controller.snapshot.session.autoShip == false)
#expect(await controller.snapshot.session.autoShipFailed == true)
// Re-enabling autoship clears the marker (the user opted back in).
await controller.setAutoShip(true)
#expect(await controller.snapshot.session.autoShip == true)
#expect(await controller.snapshot.session.autoShipFailed == false)
}
@Test func conversationalModeResumesPerTurnAndStaysAlive() async throws {
// Each turn is its own single-shot run that emits a terminal runFinished;
// in conversational mode that means "awaiting input", and the next message