From 90a4e41ed29b3de1b353d9461b9f438da7b88af6 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 15:10:02 +0100 Subject: [PATCH] fix(ios): guest removal actually clears the composer chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X on the iOS guest chip never removed the guest. Two compounding causes: 1. removeGuestParticipant marks the guest CHILD chat lifecycleState:'closed' (it is not deleted), but the projection never sent the lifecycle, and the phone detects the active guest purely from that child card (isGuestSideChat). So the closed child kept satisfying the detector and the chip lingered. 2. The inline X was an 8pt glyph with no frame (~8x8pt hit area, well under the 44pt minimum), so taps usually missed — the user fell back to re-picking a provider, which made the guest agent acknowledge the change without removing it (the "ensemble participant change" they saw). Fix: - Mac: project sideChatLifecycleState on the task card. - iOS: RemoteTaskCard.sideChatIsActive (absent => active, back-compat); guestParticipant(of:) now requires isGuestSideChat && sideChatIsActive, so a removed guest drops out and the chip clears. - iOS UX: give the X a 22pt hit target + a11y label, and add a destructive "Remove guest" item to the provider/model picker menu (per the user's suggestion) as a reliable, discoverable removal path. Tests: RemoteTaskProjection projects active/closed lifecycle; new GuestCardLifecycleTests pins the decode + sideChatIsActive predicate (active/closed/terminated/absent). Needs both a Mac release (projection) and an iOS build to take effect end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/TaskWraithKit/Models.swift | 12 ++++ .../TaskWraithUI/RemoteSessionModel.swift | 5 +- .../Sources/TaskWraithUI/TWSharedViews.swift | 21 +++++- .../GuestCardLifecycleTests.swift | 68 +++++++++++++++++++ src/main/RemoteTaskProjection.test.ts | 28 +++++++- src/main/RemoteTaskProjection.ts | 9 +++ 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 ios/TaskWraithKit/Tests/TaskWraithKitTests/GuestCardLifecycleTests.swift 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 } : {}),