Merge nucleic/olive-iris-yak-q2qm into dev

This commit is contained in:
2026-07-28 19:24:22 -07:00
parent 1b022fd835
commit 04590918c0
5 changed files with 67 additions and 11 deletions
@@ -875,7 +875,8 @@ final class RemoteStore: ObservableObject {
private func isQueueableIntent(_ msg: ClientMsg) -> Bool {
switch msg {
case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo,
.renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate,
.renameSession, .setFavorite, .setArchived, .markSessionDone, .deleteSession,
.discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond, .resolveProcessStall:
return true
@@ -1261,7 +1262,8 @@ final class RemoteStore: ObservableObject {
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true,
canMarkSessionDone: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
@@ -2696,6 +2698,26 @@ final class RemoteStore: ObservableObject {
}
func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
/// Hand-mark a chat "Done" — the phone's counterpart of the Mac sidebar's "Mark Done", for a
/// chat stuck reading "Awaiting Input" whose work is actually finished. The host flips the last
/// turn's disposition to `.completed` (and finalizes the chat the same way its own classifier
/// would, so an autoship-armed chat ships), then broadcasts the refreshed summary. Guarded on
/// `canMarkDone` so a tap can't fire against a chat the host would reject — and so a stale
/// button (the row changed state between render and tap) is inert rather than confusing.
func markSessionDone(_ id: SessionID) {
guard let summary = sessions.first(where: { $0.sessionID == id }), canMarkDone(summary) else { return }
send(.markSessionDone(id))
}
/// Whether "Mark Done" applies to this chat: the row reads "Awaiting Input" (the only state the
/// disposition refines, so anything else is a host-side no-op), it isn't already Done, this
/// device holds control scope, and the owning Mac speaks the verb. Mirrors the Mac sidebar's
/// own gate; a moved-away tombstone is excluded — its chat lives on another Mac now.
func canMarkDone(_ summary: WireSessionSummary) -> Bool {
canControl && capabilities.canMarkSessionDone && summary.movedTo == nil
&& summary.status == .awaitingInput && summary.disposition != .completed
}
func deleteSession(_ id: SessionID) {
send(.deleteSession(id))
if id == openSessionID { closeOpen() }
@@ -2845,7 +2867,8 @@ final class RemoteStore: ObservableObject {
break
case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
.setArchived(let id, _), .markSessionDone(let id),
.setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
.resolveProcessStall(let id, _, _),
@@ -3066,6 +3089,10 @@ final class RemoteStore: ObservableObject {
demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
case .setArchived(let id, let archived):
demoUpdateSession(id) { $0.demoCopy(archived: archived) }
case .markSessionDone(let id):
// Same shape as the host's: the row keeps its `.awaitingInput` status and just stops
// reading "needs you", because the disposition — not the status — is what refines it.
demoUpdateSession(id) { $0.demoCopy(disposition: .some(.completed)) }
case .deleteSession(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
@@ -399,6 +399,16 @@ private struct SplitSidebar: View {
} label: {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
}.tint(.yellow)
// Behind Favorite, for the same reason as the compact list:
// the first leading button is the full-swipe action, and
// marking done can enqueue an autoship merge.
if store.canMarkDone(summary) {
Button {
store.markSessionDone(summary.sessionID)
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}.tint(Palette.success)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
@@ -322,6 +322,14 @@ struct SessionDetailView: View {
@ViewBuilder
private func sessionActions(_ summary: WireSessionSummary) -> some View {
// "Awaiting Input" that's actually done: flip it to "Done" by hand so a chat stuck on the
// wrong state clears without spending another turn on it. Offered only while the gate holds
// (`store.canMarkDone`), mirroring the Mac sidebar's own condition.
if store.canMarkDone(summary) {
Button { store.markSessionDone(sessionID) } label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
}
Button { renameDraft = summary.title; showRename = true } label: {
Label("Rename", systemImage: "pencil")
}
@@ -127,8 +127,9 @@ struct SessionsView: View {
}
}
/// One tappable session row with its favorite / delete / archive swipe actions. Shared by
/// the live sections (Needs you / Running / Done) and the collapsible Archived section.
/// One tappable session row with its mark-done / favorite / delete / archive swipe actions.
/// Shared by the live sections (Needs you / Running / Done) and the collapsible Archived
/// section.
@ViewBuilder
private func sessionRow(_ summary: WireSessionSummary) -> some View {
NavigationLink(value: summary.sessionID) {
@@ -141,6 +142,17 @@ struct SessionsView: View {
} label: {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
}.tint(.yellow)
// "Mark Done" rides behind Favorite deliberately. The leading edge's *first* button is
// also its full-swipe action, and marking done is the same declaration the classifier
// makes — on an autoship-armed chat it enqueues the merge — so it must take a
// deliberate tap, not a flick. Favorite keeps the full swipe it always had.
if store.canMarkDone(summary) {
Button {
store.markSessionDone(summary.sessionID)
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}.tint(Palette.success)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {