diff --git a/ios/TaskWraithKit/Sources/TaskWraithKit/Models.swift b/ios/TaskWraithKit/Sources/TaskWraithKit/Models.swift index 29586df7..a7be53e5 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithKit/Models.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithKit/Models.swift @@ -378,6 +378,11 @@ public struct RemoteTaskCard: Codable, Sendable { public let agentAccent: String? public let agentSlug: String? public let sideChatMode: String? + /// Side-chat lifecycle from the Mac (`active` | `closed` | `terminated`). + /// Absent ⇒ treat as active (older Mac builds didn't project it). A removed + /// guest's child chat is marked `closed` (not deleted), so the active-guest + /// detector filters on this to clear the composer chip on removal. + public let sideChatLifecycleState: String? public let chatKind: String? /// Unstarted welcome-card draft (0 messages/runs). Kept in the card set so /// the in-progress welcome screen resolves, but filtered out of list @@ -435,6 +440,13 @@ public struct RemoteTaskCard: Codable, Sendable { public var isGuestSideChat: Bool { parentChatRelation == "sideChat" && sideChatMode == "guestParticipant" } + /// A side chat the Mac still considers live. Absent lifecycle ⇒ active, so + /// older Mac builds (pre-`sideChatLifecycleState`) keep showing the guest. + /// A removed guest's child becomes `closed`, so this drops it from the + /// ACTIVE-guest detector while leaving it visible in the Side-chats history. + public var sideChatIsActive: Bool { + (sideChatLifecycleState ?? "active") == "active" + } public var isSubThread: Bool { parentChatRelation == "subThread" || (parentChatId != nil && parentChatRelation == nil) } diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift index 75bae860..f98c1328 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift @@ -1922,8 +1922,11 @@ public final class RemoteSessionModel: ObservableObject { } /// The current guest participant child of a thread, if any. + /// Filters on `sideChatIsActive` so a removed guest (whose child the Mac + /// marks `closed` rather than deleting) drops out — otherwise the composer + /// guest chip lingers after the user removes the guest. public func guestParticipant(of threadId: String) -> RemoteTaskCard? { - taskCards.first { $0.parentChatId == threadId && $0.isGuestSideChat } + taskCards.first { $0.parentChatId == threadId && $0.isGuestSideChat && $0.sideChatIsActive } } /// Invite / change the guest participant on a solo thread. diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift index 7c11cdfa..79ee4434 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift @@ -5233,12 +5233,19 @@ public struct GuestParticipantControl: View { model.removeGuestParticipant(card) } label: { Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) + .font(.system(size: 9, weight: .bold)) .foregroundStyle(TWTheme.textMuted) + // An 8pt glyph with no frame gave a ~8×8pt tap target + // (far below the 44pt minimum), so taps missed and the + // guest never got removed. Give it real hit area. + .frame(width: 22, height: 22) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel("Remove guest") } - .padding(.horizontal, 7) + .padding(.leading, 7) + .padding(.trailing, 1) .padding(.vertical, 3) .background(guestAccent.opacity(0.12), in: Capsule()) .overlay(Capsule().strokeBorder(guestAccent.opacity(0.4))) @@ -5273,6 +5280,16 @@ public struct GuestParticipantControl: View { } } } + // A reliable removal path from the provider/model picker itself — the + // tiny inline X is easy to miss. Only meaningful once a guest exists. + if guest != nil { + Divider() + Button(role: .destructive) { + model.removeGuestParticipant(card) + } label: { + Label("Remove guest", systemImage: "person.crop.circle.badge.minus") + } + } } @ViewBuilder diff --git a/ios/TaskWraithKit/Tests/TaskWraithKitTests/GuestCardLifecycleTests.swift b/ios/TaskWraithKit/Tests/TaskWraithKitTests/GuestCardLifecycleTests.swift new file mode 100644 index 00000000..7d5a630c --- /dev/null +++ b/ios/TaskWraithKit/Tests/TaskWraithKitTests/GuestCardLifecycleTests.swift @@ -0,0 +1,68 @@ +// Guest-card lifecycle decode — the wire contract behind "remove guest". +// +// removeGuestParticipant on the Mac clears the parent's guestParticipant and +// marks the guest CHILD chat `lifecycleState: 'closed'` (it is NOT deleted). +// The phone detects the active guest purely from that child card, so it must +// read `sideChatLifecycleState` and treat a closed child as no-longer-the-guest +// — otherwise the composer guest chip lingers after removal. This pins the +// decode + the `sideChatIsActive` predicate that the active-guest detector uses. + +import Foundation +import Testing + +@testable import TaskWraithKit + +@Suite("Guest card lifecycle") +struct GuestCardLifecycleTests { + private func card(_ json: String) throws -> RemoteTaskCard { + try JSONDecoder().decode(RemoteTaskCard.self, from: Data(json.utf8)) + } + + @Test("an active guest child is detected and live") + func activeGuest() throws { + let c = try card( + """ + {"id":"guest-1","parentChatId":"parent-1","parentChatRelation":"sideChat", + "sideChatMode":"guestParticipant","sideChatLifecycleState":"active","workspaceId":null} + """) + #expect(c.isGuestSideChat) + #expect(c.sideChatIsActive) + } + + @Test("a removed (closed) guest child stays a guest by mode but is no longer active") + func closedGuest() throws { + let c = try card( + """ + {"id":"guest-1","parentChatId":"parent-1","parentChatRelation":"sideChat", + "sideChatMode":"guestParticipant","sideChatLifecycleState":"closed","workspaceId":null} + """) + // Still labelled "Guest" in the side-chats history… + #expect(c.isGuestSideChat) + // …but the active-guest detector (isGuestSideChat && sideChatIsActive) + // drops it, so the composer chip clears. + #expect(!c.sideChatIsActive) + #expect(!(c.isGuestSideChat && c.sideChatIsActive)) + } + + @Test("a terminated guest child is also not active") + func terminatedGuest() throws { + let c = try card( + """ + {"id":"guest-1","parentChatId":"parent-1","parentChatRelation":"sideChat", + "sideChatMode":"guestParticipant","sideChatLifecycleState":"terminated","workspaceId":null} + """) + #expect(!c.sideChatIsActive) + } + + @Test("absent lifecycle is treated as active (older Mac builds keep showing the guest)") + func absentLifecycleIsActive() throws { + let c = try card( + """ + {"id":"guest-1","parentChatId":"parent-1","parentChatRelation":"sideChat", + "sideChatMode":"guestParticipant","workspaceId":null} + """) + #expect(c.isGuestSideChat) + #expect(c.sideChatIsActive) + #expect(c.isGuestSideChat && c.sideChatIsActive) + } +} diff --git a/src/main/RemoteTaskProjection.test.ts b/src/main/RemoteTaskProjection.test.ts index e10e0cb8..023274e7 100644 --- a/src/main/RemoteTaskProjection.test.ts +++ b/src/main/RemoteTaskProjection.test.ts @@ -333,7 +333,8 @@ describe('RemoteTaskProjection', () => { id: 'guest-1', parentChatId: 'parent-1', parentChatRelation: 'sideChat', - sideChatMode: 'guestParticipant' + sideChatMode: 'guestParticipant', + sideChatLifecycleState: 'active' }) expect(sideChat).toMatchObject({ id: 'side-1', @@ -348,6 +349,31 @@ describe('RemoteTaskProjection', () => { }) }) + it('projects the closed lifecycle of a removed guest so the phone can drop its chip', () => { + // removeGuestParticipant marks the guest child `closed` (it is not deleted), + // so the card MUST carry the lifecycle for the phone's active-guest detector + // to filter it out — otherwise the composer guest chip lingers after removal. + const closedGuest = buildRemoteTaskCard( + chat({ + appChatId: 'guest-closed', + parentChatId: 'parent-1', + parentChatRelation: 'sideChat', + sideChatContext: { + createdAt: NOW, + mode: 'guestParticipant', + lifecycleState: 'closed', + closedAt: NOW, + transcriptVisibility: 'none' + } + }) + ) + expect(closedGuest).toMatchObject({ + id: 'guest-closed', + sideChatMode: 'guestParticipant', + sideChatLifecycleState: 'closed' + }) + }) + it('summarises RunDiffResult arrays and runDiffByPath workspace changes', () => { const summary = buildMobileDiffSummary( run({ diff --git a/src/main/RemoteTaskProjection.ts b/src/main/RemoteTaskProjection.ts index a2f196c6..5796c648 100644 --- a/src/main/RemoteTaskProjection.ts +++ b/src/main/RemoteTaskProjection.ts @@ -109,6 +109,12 @@ export interface RemoteTaskCard { /** When `parentChatRelation === 'sideChat'`, the side-chat mode * (`guestParticipant`, `ensembleClone`, `fanOut`, …). */ sideChatMode?: string + /** When `parentChatRelation === 'sideChat'`, the side-chat lifecycle + * (`active` | `closed` | `terminated`). Absent ⇒ treat as active. A removed + * guest's child chat is marked `closed` (not deleted) by the store, so the + * phone must read this to drop it from the ACTIVE-guest detector — otherwise + * the composer guest chip lingers after removal. */ + sideChatLifecycleState?: string /** `ensemble` chats need `ensembleQueuePrompt` on remote send paths. */ chatKind?: 'single' | 'ensemble' /** Unstarted iOS welcome-card draft (0 messages/runs). Remote clients keep @@ -732,6 +738,9 @@ export function buildRemoteTaskCard( ...(agentIdentity?.slug ? { agentSlug: agentIdentity.slug } : {}), ...(chat.parentChatRelation ? { parentChatRelation: chat.parentChatRelation } : {}), ...(chat.sideChatContext?.mode ? { sideChatMode: chat.sideChatContext.mode } : {}), + ...(chat.sideChatContext?.lifecycleState + ? { sideChatLifecycleState: chat.sideChatContext.lifecycleState } + : {}), ...(chat.chatKind ? { chatKind: chat.chatKind } : {}), ...(isContentlessRemoteDraftChat(chat) ? { isDraft: true } : {}), ...(chat.archived ? { archived: true } : {}),