Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ios/TaskWraithKit/Sources/TaskWraithKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 27 additions & 1 deletion src/main/RemoteTaskProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions src/main/RemoteTaskProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading