diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 05af4d3..05a4660 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6a454662a64d0761bc14f81f12302560e4ac3eba029e64f1e0b0b7f004590631", + "originHash" : "290676a4813b700fd9b3b86cca9dedcd7359ac250b5ce400dbc58caed6255b4a", "pins" : [ { "identity" : "async-http-client", @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", - "version" : "2.101.2" + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" } }, { @@ -202,10 +202,9 @@ { "identity" : "swift-nio-ssl", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", + "location" : "https://github.com/Joannis/swift-nio-ssl.git", "state" : { - "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", - "version" : "2.37.1" + "revision" : "bc3bd90098701c6c1d7aa6275b6911860fe9d439" } }, { diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index baf5496..0ea4649 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index a40699c..67a1695 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -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) { diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index a424c69..ceb6dee 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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") } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 1fe677a..14c98aa 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -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) {