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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user