diff --git a/apps/ade-cli/src/services/push/attentionItemBuilder.ts b/apps/ade-cli/src/services/push/attentionItemBuilder.ts index c6f7d641e..83d7f4d15 100644 --- a/apps/ade-cli/src/services/push/attentionItemBuilder.ts +++ b/apps/ade-cli/src/services/push/attentionItemBuilder.ts @@ -106,14 +106,8 @@ export type PushPrNotification = { repoName?: string | null; }; -/** - * A roster chat as the publisher reads it. `identityKey` is an additive label - * rosterBuilder stamps on CTO/identity chats. It stays optional so any roster - * provider that never sets it still satisfies this contract — the sync roster - * keeps carrying identity rows for the mobile hub, and only this feed drops - * them, mirroring what the desktop sidebar already does. - */ -export type ActivityRosterChat = SyncRosterChat & { identityKey?: string | null }; +/** A roster chat as the publisher reads it; identity rows are filtered upstream. */ +export type ActivityRosterChat = SyncRosterChat; export type ActivityRosterProject = Omit & { chats: ActivityRosterChat[]; }; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index 51f8a65fd..1bb811315 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -359,10 +359,7 @@ describe("buildRosterSnapshot", () => { expect(projects[0]!.runningCount).toBe(0); }); - it("labels CTO/identity chats without dropping them from the roster", async () => { - // The mobile hub consumes this roster and renders identity chats, so the - // roster must keep the rows. It only labels them; excluding them is the - // Activity publisher's job (see pushPublisherService). + it("excludes CTO/identity chats from the roster and its counts", async () => { // Disk path: the persisted sidecar is the only identity signal available // for an un-booted project. fs.writeFileSync( @@ -371,8 +368,9 @@ describe("buildRosterSnapshot", () => { ); const fromDisk = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); const diskRow = fromDisk[0]!.chats.find((chat) => chat.id === "chat-run"); - expect(diskRow).toBeDefined(); - expect((diskRow as { identityKey?: string | null } | undefined)?.identityKey).toBe("cto"); + expect(diskRow).toBeUndefined(); + expect(fromDisk[0]!.chats.find((chat) => chat.id === "cli-end")).toBeUndefined(); + expect(fromDisk[0]!.attentionCount).toBe(1); // Booted path: the live summary carries identityKey directly. const scopeRegistry = bootedScopes([ @@ -380,10 +378,8 @@ describe("buildRosterSnapshot", () => { ]); const fromLive = await buildRosterSnapshot({ projectRegistry, scopeRegistry, hostProjectId: PROJECT_ID }); const liveRow = fromLive[0]!.chats.find((chat) => chat.id === "chat-await"); - expect(liveRow).toBeDefined(); - expect((liveRow as { identityKey?: string | null } | undefined)?.identityKey).toBe("cto"); - // Hub behaviour is unchanged: an awaiting identity chat still badges. - expect(fromLive[0]!.attentionCount).toBe(1); + expect(liveRow).toBeUndefined(); + expect(fromLive[0]!.attentionCount).toBe(0); }); it("leaves ordinary chats unlabelled", async () => { diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index 5fb44ff24..fc4127bf5 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -66,27 +66,20 @@ export type RosterLiveSession = { */ activeBackgroundTaskCount?: number | null; /** - * Set on CTO/identity chats. The roster still carries these rows — the mobile - * hub renders them — but the Activity feed must not, because the desktop - * sidebar strips them and the two counts have to agree. The roster's job is - * only to label them; see `RosterChat.identityKey`. + * Set on CTO/identity chats. Identity sessions are deliberately omitted from + * the normal project roster, but this marker remains available as a + * defensive signal for older or alternate roster producers. */ identityKey?: string | null; }; -/** - * A roster chat plus the additive `identityKey` label. Deliberately NOT folded - * into the shared `SyncRosterChat` wire type: this is a hint for one consumer - * (the Activity publisher, which excludes identity chats from the feed), and - * every other consumer — the mobile hub above all — keeps rendering the rows - * exactly as before and simply ignores the extra field. - */ -export type RosterChat = SyncRosterChat & { identityKey?: string | null }; +/** A roster chat with the optional identity marker retained for compatibility. */ +export type RosterChat = SyncRosterChat; export type RosterAgentChatService = { listSessions( laneId?: string, - options?: { includeArchived?: boolean }, + options?: { includeArchived?: boolean; includeIdentity?: boolean }, ): Promise; }; @@ -452,7 +445,7 @@ async function buildRosterProject( if (agentChatService) { booted = true; const liveSessions = await agentChatService - .listSessions(undefined, { includeArchived: false }) + .listSessions(undefined, { includeArchived: false, includeIdentity: true }) .catch(() => [] as RosterLiveSession[]); for (const live of liveSessions) { if (live?.sessionId) liveBySessionId.set(live.sessionId, live); @@ -467,13 +460,39 @@ async function buildRosterProject( .sort(compareLanes); const visibleLaneIds = new Set(visibleLanes.map((lane) => lane.id)); + const visibleRows = desktopVisibleRosterRows(disk.chats, visibleLaneIds); + const identitySessionIds = new Set(); + for (const row of visibleRows) { + const liveIdentityKey = liveBySessionId.get(row.id)?.identityKey?.trim() || null; + const diskIdentityKey = readChatSidecar(chatSessionsDir, row.id)?.identityKey?.trim() || null; + if (liveIdentityKey || diskIdentityKey) identitySessionIds.add(row.id); + } + let identityDescendantAdded = true; + while (identityDescendantAdded) { + identityDescendantAdded = false; + for (const row of visibleRows) { + const parentSessionId = normalizedParentSessionId(row); + if (parentSessionId + && identitySessionIds.has(parentSessionId) + && !identitySessionIds.has(row.id)) { + identitySessionIds.add(row.id); + identityDescendantAdded = true; + } + } + } + const chats: RosterChat[] = []; let runningCount = 0; let attentionCount = 0; - for (const row of desktopVisibleRosterRows(disk.chats, visibleLaneIds)) { + for (const row of visibleRows) { const live = liveBySessionId.get(row.id); const sidecar = readChatSidecar(chatSessionsDir, row.id); - const identityKey = live?.identityKey ?? sidecar?.identityKey ?? null; + const identityKey = (live?.identityKey ?? sidecar?.identityKey ?? null) + ?.trim() || null; + // CTO/identity sessions have their own surface and attention path. They + // must never become ordinary project-roster rows or contribute to Hub + // counts, even when their sidecar is the only identity signal available. + if (identitySessionIds.has(row.id)) continue; // CLI (terminal) sessions never appear in agentChatService; on a booted // scope their liveness comes from the PTY table instead. const hasLivePty = livePtyService?.hasLivePty(row.id) === true; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index cf56056e9..11c88ae6f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1834,6 +1834,24 @@ describe("createSyncRemoteCommandService", () => { ]); }); + it("forwards identity-aware chat roster reads to the chat service", async () => { + const listSessions = vi.fn().mockResolvedValue([{ id: "chat-1", identityKey: "project-cto" }]); + const { service } = createService({ agentChatService: { listSessions } }); + + await expect(service.execute(makePayload("chat.listSessions", { + laneId: " lane-1 ", + includeAutomation: true, + includeArchived: false, + includeIdentity: true, + }))).resolves.toEqual([{ id: "chat-1", identityKey: "project-cto" }]); + + expect(listSessions).toHaveBeenCalledWith("lane-1", { + includeAutomation: true, + includeArchived: false, + includeIdentity: true, + }); + }); + it("routes work.getSession through session enrichment and chat state projection", async () => { const session = { id: "session-1", status: "running", toolType: "codex-chat", ptyId: "pty-1" }; const enrichedSession = { ...session, runtimeState: "running" }; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 326543fee..9fdc4f2a5 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2358,6 +2358,7 @@ function parseAgentChatListArgs(value: Record): AgentChatListAr ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), includeAutomation: asOptionalBoolean(value.includeAutomation), includeArchived: asOptionalBoolean(value.includeArchived), + includeIdentity: asOptionalBoolean(value.includeIdentity), }; } @@ -4451,6 +4452,7 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation, includeArchived: parsed.includeArchived, + includeIdentity: parsed.includeIdentity, }); }); register("chat.getSummary", { viewerAllowed: true }, async (payload) => diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c2d09998b..704a2ff0a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7558,12 +7558,16 @@ export function registerIpc({ return []; } const laneId = typeof arg?.laneId === "string" ? arg.laneId.trim() : ""; + const listOptions = { + includeAutomation: Boolean(arg?.includeAutomation), + ...(arg?.includeIdentity === true ? { includeIdentity: true } : {}), + }; return await (service as unknown as { listSessions: ( laneId?: string, - options?: { includeAutomation?: boolean }, + options?: { includeAutomation?: boolean; includeIdentity?: boolean }, ) => Promise; - }).listSessions(laneId || undefined, { includeAutomation: Boolean(arg?.includeAutomation) }); + }).listSessions(laneId || undefined, listOptions); }); ipcMain.handle(IPC.agentChatGetSummary, async (_event, arg: AgentChatGetSummaryArgs): Promise => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d37b5df10..6b66dafd4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6135,7 +6135,10 @@ contextBridge.exposeInMainWorld("ade", { >("chat", "listSessions", { argsList: [ args.laneId, - { includeAutomation: args.includeAutomation === true }, + { + includeAutomation: args.includeAutomation === true, + includeIdentity: args.includeIdentity === true, + }, ], }); return runtime.handled diff --git a/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts b/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts index b31de92ac..65d6c7e0b 100644 --- a/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts +++ b/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts @@ -66,6 +66,23 @@ describe("agentChatSessionListCache", () => { expect(list).toHaveBeenCalledTimes(1); }); + it("keeps identity-aware reads separate from the ordinary chat cache", async () => { + const list = vi.mocked(window.ade.agentChat.list); + list + .mockResolvedValueOnce([session("ordinary-session")]) + .mockResolvedValueOnce([session("cto-session")]); + + await expect( + listAgentChatSessionsCached({ laneId: "lane-1", includeIdentity: false }), + ).resolves.toEqual([session("ordinary-session")]); + await expect( + listAgentChatSessionsCached({ laneId: "lane-1", includeIdentity: true }), + ).resolves.toEqual([session("cto-session")]); + + expect(list).toHaveBeenNthCalledWith(1, { laneId: "lane-1", includeIdentity: false }); + expect(list).toHaveBeenNthCalledWith(2, { laneId: "lane-1", includeIdentity: true }); + }); + it("lets a forced refresh supersede an in-flight read without stale cache overwrite", async () => { let resolveFirst: (rows: AgentChatSessionSummary[]) => void = () => {}; const firstPending = new Promise((resolve) => { diff --git a/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts index 1c0c4c08e..fb7b61fe8 100644 --- a/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts +++ b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts @@ -22,6 +22,7 @@ function normalizeArgs(args?: AgentChatListArgs): AgentChatListArgs { if (args?.laneId?.trim()) normalized.laneId = args.laneId.trim(); if (typeof args?.includeAutomation === "boolean") normalized.includeAutomation = args.includeAutomation; if (typeof args?.includeArchived === "boolean") normalized.includeArchived = args.includeArchived; + if (typeof args?.includeIdentity === "boolean") normalized.includeIdentity = args.includeIdentity; return normalized; } @@ -34,6 +35,7 @@ function cacheKey(args?: AgentChatListArgs): string { laneId: normalized.laneId ?? null, includeAutomation: normalized.includeAutomation ?? null, includeArchived: normalized.includeArchived ?? null, + includeIdentity: normalized.includeIdentity ?? null, }); } diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 4c6defddf..24216ae06 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -2413,6 +2413,8 @@ export type AgentChatListArgs = { laneId?: string; includeAutomation?: boolean; includeArchived?: boolean; + /** Include identity-bound sessions for dedicated surfaces such as CTO. */ + includeIdentity?: boolean; }; export type AgentChatSuggestLaneNameArgs = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 7ccaec72e..0e6823a4e 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -788,6 +788,11 @@ export type SyncRosterChat = { laneId: string; /** Parent chat/session id for attached shell rows. Mirrors TerminalSessionSummary.chatSessionId. */ chatSessionId?: string | null; + /** + * Identity-session marker used to reject stale/legacy roster rows on clients. + * Current hosts omit identity sessions from the normal roster entirely. + */ + identityKey?: string | null; title?: string | null; provider?: string | null; model?: string | null; diff --git a/apps/ios/ADE/Models/RemoteRosterModels.swift b/apps/ios/ADE/Models/RemoteRosterModels.swift index 16f2e1be3..c9d25ac45 100644 --- a/apps/ios/ADE/Models/RemoteRosterModels.swift +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -54,6 +54,10 @@ struct RemoteRosterChat: Codable, Equatable, Identifiable { var snoozedAt: String? = nil var wokeAt: String? = nil var wokeReason: String? = nil + /// CTO/identity sessions have a dedicated surface and must not enter the + /// ordinary project roster. The field is optional so older hosts remain + /// decodable; clients use it to reject stale or legacy leaked rows. + var identityKey: String? = nil } struct RemoteRosterLane: Codable, Equatable, Identifiable { @@ -123,6 +127,7 @@ func resolveRosterSessionNavigationTarget( let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedRepoOwner = repoOwner?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedRepoName = repoName?.trimmingCharacters(in: .whitespacesAndNewlines) + let safeRosterProjects = rosterProjects.map { $0.excludingIdentityChats() } func catalogProjects(owner: String?, named name: String) -> [MobileProjectSummary] { projects.filter { project in @@ -160,9 +165,9 @@ func resolveRosterSessionNavigationTarget( } let candidateRosterProjects: [RemoteRosterProject] = { - guard let scopedCatalogProject else { return rosterProjects } + guard let scopedCatalogProject else { return safeRosterProjects } let catalogRoot = syncNormalizedProjectRootScope(scopedCatalogProject.rootPath) - let exactMatches = rosterProjects.filter { project in + let exactMatches = safeRosterProjects.filter { project in if project.projectId == scopedCatalogProject.id { return true } guard let catalogRoot else { return false } return syncNormalizedProjectRootScope(project.rootPath) == catalogRoot @@ -175,14 +180,14 @@ func resolveRosterSessionNavigationTarget( // the catalog, a same-named roster from another owner is never safe. if normalizedRepoOwner?.isEmpty == false { return [] } guard let normalizedRepoName else { return [] } - let nameMatches = rosterProjects.filter { + let nameMatches = safeRosterProjects.filter { $0.displayName.caseInsensitiveCompare(normalizedRepoName) == .orderedSame } return nameMatches.count == 1 ? nameMatches : [] }() var rosterProject = candidateRosterProjects.first { project in - project.chats.contains { $0.id == sessionId } + project.chats.contains { $0.id == sessionId && !$0.isIdentityChat } } if rosterProject == nil, let normalizedLaneId, !normalizedLaneId.isEmpty { rosterProject = candidateRosterProjects.first { project in @@ -219,7 +224,7 @@ func resolveRosterSessionNavigationTarget( }() guard let catalogProject else { return nil } - let resolvedRoster = rosterProject ?? rosterProjects.first { project in + let resolvedRoster = rosterProject ?? safeRosterProjects.first { project in project.projectId == catalogProject.id || ( syncNormalizedProjectRootScope(project.rootPath) != nil @@ -227,7 +232,7 @@ func resolveRosterSessionNavigationTarget( == syncNormalizedProjectRootScope(catalogProject.rootPath) ) } - let rosterChat = resolvedRoster?.chats.first { $0.id == sessionId } + let rosterChat = resolvedRoster?.chats.first { $0.id == sessionId && !$0.isIdentityChat } let resolvedLane: RemoteRosterLane? = { guard let resolvedRoster else { return nil } // Once the authoritative chat exists, its lane owns the relationship. @@ -317,16 +322,27 @@ func rosterApplyDelta( if delta.seq > currentSeq + 1 { return .needsSnapshot } var byId = [String: RemoteRosterProject]() for project in current { - byId[project.projectId] = project + byId[project.projectId] = project.excludingIdentityChats() } for projectId in delta.removed ?? [] { byId.removeValue(forKey: projectId) } - for project in delta.changed ?? [] { byId[project.projectId] = project } + for project in delta.changed ?? [] { + byId[project.projectId] = project.excludingIdentityChats() + } return .applied(projects: Array(byId.values), seq: delta.seq) } // MARK: - Convenience extension RemoteRosterChat { + /// Identity sessions (currently the per-project CTO) have their own tab and + /// attention endpoint. They are never ordinary Work or Hub rows. + var isIdentityChat: Bool { + guard let identityKey = identityKey?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + return !identityKey.isEmpty + } + /// Only explicit chat tool types stream the chat-event surface. An unknown or /// missing toolType must NOT read as a chat: routing a CLI (terminal) session /// through the chat transcript path yields a permanently blank screen (CLI @@ -405,18 +421,48 @@ extension RemoteRosterChat { } extension RemoteRosterProject { + /// Remove identity rows from any roster boundary and repair the derived + /// counts that older hosts may have computed before filtering them. + func excludingIdentityChats() -> RemoteRosterProject { + var identitySessionIds = Set(chats.filter(\.isIdentityChat).map(\.id)) + var identityDescendantAdded = true + while identityDescendantAdded { + identityDescendantAdded = false + for chat in chats { + guard let parentId = chat.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty, + identitySessionIds.contains(parentId) else { continue } + if identitySessionIds.insert(chat.id).inserted { + identityDescendantAdded = true + } + } + } + let filteredChats = chats.filter { chat in + !identitySessionIds.contains(chat.id) + } + guard filteredChats.count != chats.count else { return self } + + var sanitized = self + sanitized.chats = filteredChats + sanitized.runningCount = filteredChats.filter(\.countsTowardRunning).count + sanitized.attentionCount = filteredChats.filter(\.needsAttention).count + return sanitized + } + /// Chats for one lane, freshest first. Archived rows are filtered out for the /// hub's at-a-glance view. func chats(forLaneId laneId: String) -> [RemoteRosterChat] { chats - .filter { $0.laneId == laneId && $0.archived != true } + .filter { $0.laneId == laneId && $0.archived != true && !$0.isIdentityChat } .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } } /// Lanes that actually have at least one non-archived chat, preserving the /// brain-provided order (primary lane first). var lanesWithChats: [RemoteRosterLane] { - lanes.filter { lane in chats.contains { $0.laneId == lane.id && $0.archived != true } } + lanes.filter { lane in + chats.contains { $0.laneId == lane.id && $0.archived != true && !$0.isIdentityChat } + } } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 399f57ce6..6d478b990 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8889,7 +8889,11 @@ final class SyncService: ObservableObject { } func ensureCtoSession() async throws -> AgentChatSessionSummary { - try await sendDecodableCommand(action: "cto.ensureSession", as: AgentChatSessionSummary.self) + let summary = try await sendDecodableCommand(action: "cto.ensureSession", as: AgentChatSessionSummary.self) + // Keep the identity marker available to the local Work/activity guards in + // case the CRR session row arrives before the next roster snapshot. + cacheChatSummary(summary) + return summary } // MARK: - CTO state + memory @@ -12215,7 +12219,15 @@ final class SyncService: ObservableObject { func listChatSessions(laneId: String) async throws -> [AgentChatSessionSummary] { try await sendDecodableCommand( action: "chat.listSessions", - args: ["laneId": laneId, "includeAutomation": true, "includeArchived": true], + // Work must see the identity marker even though identity rows are never + // rendered there. The marker lets the local projection remove a CTO row + // before a roster snapshot or CRR update catches up. + args: [ + "laneId": laneId, + "includeAutomation": true, + "includeArchived": true, + "includeIdentity": true, + ], as: [AgentChatSessionSummary].self ) } @@ -20249,6 +20261,7 @@ extension SyncService { // running roster, but can still appear in the in-app Activity drawer via // `allAgents`. let runningRecencyCutoff = now.addingTimeInterval(-120) + let identitySessionIds = identitySessionIdsForSessions(sessions) for session in sessions { let isChat = isWorkChatToolType(session.toolType) @@ -20256,6 +20269,10 @@ extension SyncService { guard session.archivedAt == nil else { continue } let summary = chatSummaryCache[session.id] + // The CTO has its own tab and attention path. A stale local session row + // may survive before the next roster refresh, so the identity marker is + // enforced again at the activity/widget projection boundary. + guard !identitySessionIds.contains(session.id) else { continue } let canonical = workCanonicalSessionState(session: session, summary: summary, now: now) let status = session.status.lowercased() let isFailedStatus = canonical.phase == .failed @@ -21182,7 +21199,7 @@ extension SyncService { } func applyRosterSnapshot(_ snapshot: RemoteRosterSnapshotPayload) { - let nextProjects = sortRosterProjects(snapshot.projects) + let nextProjects = sortRosterProjects(snapshot.projects.map { $0.excludingIdentityChats() }) let changedProjectIds = rosterChangedProjectIds(previous: rosterProjects, next: nextProjects) rosterProjects = nextProjects rosterSeq = snapshot.seq @@ -21202,7 +21219,7 @@ extension SyncService { case .dropped: break // duplicate / out-of-order replay case let .applied(projects, seq): - let nextProjects = sortRosterProjects(projects) + let nextProjects = sortRosterProjects(projects.map { $0.excludingIdentityChats() }) // A delta already carries its changed/removed project ids. Avoid a deep // all-project chat-array comparison on the MainActor every 250 ms. let changedProjectIds = Set((delta.changed ?? []).map(\.projectId)) @@ -21241,7 +21258,7 @@ extension SyncService { guard let activeProject, let roster = rosterProject(for: activeProject), let chat = roster.chats.first(where: { - $0.id == sessionId && $0.archived != true && $0.isChatTool + $0.id == sessionId && $0.archived != true && $0.isChatTool && !$0.isIdentityChat }) else { return nil } let laneName = roster.lanes.first(where: { $0.id == chat.laneId })?.name ?? chat.laneId @@ -21318,7 +21335,7 @@ extension SyncService { guard let data = ADESharedContainer.defaults.data(forKey: rosterCacheKey), let projects = try? JSONDecoder().decode([RemoteRosterProject].self, from: data) else { return [] } - return sortRosterProjects(projects) + return sortRosterProjects(projects.map { $0.excludingIdentityChats() }) } private func reloadRosterForActiveHost() { @@ -21361,8 +21378,12 @@ extension SyncService { guard let projectId = activeProjectId else { return nil } let lanes = database.fetchLanes(includeArchived: false) let visibleLaneIds = Set(lanes.map(\.id)) - let scopedSessions = localSessions().filter { session in - session.archivedAt == nil && visibleLaneIds.contains(session.laneId) + let sessions = localSessions() + let identitySessionIds = identitySessionIdsForSessions(sessions) + let scopedSessions = sessions.filter { session in + session.archivedAt == nil + && visibleLaneIds.contains(session.laneId) + && !identitySessionIds.contains(session.id) } let topLevelIds = Set(scopedSessions.filter { isRosterTopLevelToolType($0.toolType) }.map(\.id)) let visibleSessions = scopedSessions.filter { session in @@ -21487,7 +21508,7 @@ extension SyncService { merged.runningCount = merged.chats.filter(\.countsTowardRunning).count merged.attentionCount = merged.chats.filter(\.needsAttention).count merged.chats.sort { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } - return merged + return merged.excludingIdentityChats() } private func mergedRosterChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { @@ -21514,6 +21535,7 @@ extension SyncService { merged.model = nonEmptyRosterString(remote.model) ?? local.model merged.toolType = nonEmptyRosterString(remote.toolType) ?? local.toolType merged.chatSessionId = nonEmptyRosterString(remote.chatSessionId) ?? local.chatSessionId + merged.identityKey = nonEmptyRosterString(remote.identityKey) ?? local.identityKey merged.applyLocalSnoozeOverlay(local) return merged } @@ -21534,6 +21556,32 @@ extension SyncService { return raw.hasSuffix("-chat") } + private func isIdentityChatSummary(_ summary: AgentChatSessionSummary?) -> Bool { + guard let identityKey = summary?.identityKey?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + return !identityKey.isEmpty + } + + private func identitySessionIdsForSessions(_ sessions: [TerminalSessionSummary]) -> Set { + var identitySessionIds = Set(sessions.compactMap { session in + isIdentityChatSummary(chatSummaryCache[session.id]) ? session.id : nil + }) + var identityDescendantAdded = true + while identityDescendantAdded { + identityDescendantAdded = false + for session in sessions { + guard let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty, + identitySessionIds.contains(parentId) else { continue } + if identitySessionIds.insert(session.id).inserted { + identityDescendantAdded = true + } + } + } + return identitySessionIds + } + private func normalizedRosterParentSessionId(_ session: TerminalSessionSummary) -> String? { let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !parentId.isEmpty, parentId != session.id else { return nil } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 634158bf2..0dd23b152 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -536,8 +536,9 @@ func buildHubProjectPresentation( ) } - let laneById = Dictionary(roster.lanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) - let visibleChats = roster.chats.filter { chat in + let safeRoster = roster.excludingIdentityChats() + let laneById = Dictionary(safeRoster.lanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + let visibleChats = safeRoster.chats.filter { chat in chat.archived != true && laneById[chat.laneId] != nil } let chatToolIds = Set(visibleChats.filter(\.isChatTool).map(\.id)) @@ -561,7 +562,7 @@ func buildHubProjectPresentation( let topLevelChats = visibleChats.filter { !isChildRow($0) } let topLevelChatsByLane = Dictionary(grouping: topLevelChats, by: \.laneId) - let lanes = roster.lanes.compactMap { lane -> HubLanePresentation? in + let lanes = safeRoster.lanes.compactMap { lane -> HubLanePresentation? in let laneChats = (topLevelChatsByLane[lane.id] ?? []) .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } guard !laneChats.isEmpty else { return nil } @@ -581,11 +582,11 @@ func buildHubProjectPresentation( isActive: isActive, isSwitching: isSwitching, isLoading: false, - laneCount: roster.lanes.count, + laneCount: safeRoster.lanes.count, chatCount: chatCount, lanes: lanes, - attentionCount: roster.attentionCount, - runningCount: roster.runningCount + attentionCount: safeRoster.attentionCount, + runningCount: safeRoster.runningCount ) } diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index 49f85a15b..1f2348681 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -107,7 +107,7 @@ func makeRosterSessionStub(chat: RemoteRosterChat, lane: RemoteRosterLane?) -> T // terminal: CLI rows omit the PTY id, transcript offsets, and tracked state // required by TerminalSessionScreen. Let CLI activation hydrate its real // project row instead of manufacturing a terminal that cannot subscribe. - guard chat.isChatTool else { return nil } + guard chat.isChatTool, !chat.isIdentityChat else { return nil } return chat.asTerminalSessionSummary(laneName: lane?.name ?? chat.laneId) } diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 6b22ecdb5..ea24c3481 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -582,7 +582,7 @@ struct HubScreen: View { merged.runningCount = merged.chats.filter(\.countsTowardRunning).count merged.attentionCount = merged.chats.filter(\.needsAttention).count merged.chats.sort { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } - return merged + return merged.excludingIdentityChats() } private func mergedHubChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { @@ -603,6 +603,7 @@ struct HubScreen: View { merged.model = nonEmpty(remote.model) ?? local.model merged.toolType = nonEmpty(remote.toolType) ?? local.toolType merged.chatSessionId = nonEmpty(remote.chatSessionId) ?? local.chatSessionId + merged.identityKey = nonEmpty(remote.identityKey) ?? local.identityKey merged.applyLocalSnoozeOverlay(local) return merged } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 01a5a97b3..427e04350 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -33,6 +33,25 @@ extension WorkRootScreen { && loadedProjectionProjectId == activeProjectId let localSessions = localProjectionIsCurrent ? sessions : [] let localLanes = localProjectionIsCurrent ? lanes : [] + let chatSummariesSnapshot = localProjectionIsCurrent ? chatSummaries : [:] + let knownChatSummaries = syncService.chatSummaryCache.merging(chatSummariesSnapshot) { _, current in current } + var identitySessionIds = Set(knownChatSummaries.compactMap { sessionId, summary in + guard let identityKey = summary.identityKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !identityKey.isEmpty else { return nil } + return sessionId + }) + var identityDescendantAdded = true + while identityDescendantAdded { + identityDescendantAdded = false + for session in localSessions { + guard let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty, + identitySessionIds.contains(parentId) else { continue } + if identitySessionIds.insert(session.id).inserted { + identityDescendantAdded = true + } + } + } // The all-project roster usually learns about a newly-created chat before // the active project's CRDT replica does. Overlay only the active roster // here, at the detached presentation boundary: local hydrated rows win, @@ -42,10 +61,10 @@ extension WorkRootScreen { let rosterProjection = overlayActiveProjectRoster( localSessions: localSessions, localLanes: localLanes, - roster: activeRoster + roster: activeRoster, + identitySessionIds: identitySessionIds ) let sessionsSnapshot = rosterProjection.sessions - let chatSummariesSnapshot = localProjectionIsCurrent ? chatSummaries : [:] let deletingLaneIds = syncService.pendingLaneDeletionIds let lanesSnapshot = rosterProjection.lanes.filter { !deletingLaneIds.contains($0.id) } let pullRequestsSnapshot = localProjectionIsCurrent ? pullRequests : [] diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 0f1bc79bb..4b10f452e 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -15,7 +15,8 @@ let workActiveProjectRosterSessionLimit = 200 func overlayActiveProjectRoster( localSessions: [TerminalSessionSummary], localLanes: [LaneSummary], - roster: RemoteRosterProject? + roster: RemoteRosterProject?, + identitySessionIds: Set = [] ) -> WorkActiveProjectRosterProjection { // Match `work.listSessions(limit: 200)` and include chat rows only. Roster // terminal stubs deliberately lack PTY ids/offsets, so opening one as a @@ -23,14 +24,16 @@ func overlayActiveProjectRoster( let rosterChats = Array( (roster?.chats ?? []) .lazy - .filter { $0.archived != true && $0.isChatTool } + .filter { $0.archived != true && $0.isChatTool && !$0.isIdentityChat } .prefix(workActiveProjectRosterSessionLimit) ) var sessionIds = Set() var sessions: [TerminalSessionSummary] = [] sessions.reserveCapacity(localSessions.count + rosterChats.count) - for session in localSessions where sessionIds.insert(session.id).inserted { + for session in localSessions + where !identitySessionIds.contains(session.id) && sessionIds.insert(session.id).inserted + { sessions.append(session) } diff --git a/apps/ios/ADETests/HubProjectPresentationTests.swift b/apps/ios/ADETests/HubProjectPresentationTests.swift index 3fcdd5317..da07ab757 100644 --- a/apps/ios/ADETests/HubProjectPresentationTests.swift +++ b/apps/ios/ADETests/HubProjectPresentationTests.swift @@ -63,6 +63,28 @@ final class HubProjectPresentationTests: XCTestCase { XCTAssertNil(presentation.statusLine) } + func testIdentityChatsAreExcludedFromRowsAndDerivedCounts() { + var identity = chat(id: "cto-chat", status: .awaiting, awaitingInput: true) + identity.identityKey = "cto" + var identityChild = chat(id: "cto-shell", status: .running, awaitingInput: false) + identityChild.toolType = "shell" + identityChild.chatSessionId = identity.id + var roster = roster(attentionCount: 1, runningCount: 2) + roster.chats = [identity, identityChild, chat(id: "ordinary-chat", status: .running, awaitingInput: false)] + + let presentation = buildHubProjectPresentation( + project: project(), + roster: roster, + isActive: false, + isSwitching: false + ) + + XCTAssertEqual(presentation.attentionCount, 0) + XCTAssertEqual(presentation.runningCount, 1) + XCTAssertEqual(presentation.chatCount, 1) + XCTAssertEqual(presentation.lanes.flatMap { $0.rows }.map(\.id), ["ordinary-chat"]) + } + // MARK: - Chat row status func testChatRowStatusLabelSpeaksOnlyWhenItHasSomethingToSay() { diff --git a/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift b/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift index d81dc6cbb..a81b66ab6 100644 --- a/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift +++ b/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift @@ -466,12 +466,15 @@ final class WorkLiveRosterHydrationTests: XCTestCase { func testActiveProjectRosterOverlayKeepsLocalRowsAndAppendsMissingRosterRowsInSourceOrder() { let localLane = makeLane(id: "lane-local", name: "Local lane") let localSession = makeSession(id: "local-chat", laneId: localLane.id, laneName: localLane.name) + var identityChat = makeRosterChat(id: "cto-chat", laneId: localLane.id) + identityChat.identityKey = "cto" let roster = makeRoster(projectId: "project-1", name: "Project", lanes: [ makeRosterLane(id: localLane.id, name: "Stale local", branch: "main"), makeRosterLane(id: "lane-roster-1", name: "Roster one", branch: "feature/one"), makeRosterLane(id: "lane-roster-2", name: "Roster two", branch: "feature/two"), ], chats: [ makeRosterChat(id: localSession.id, laneId: localLane.id), + identityChat, makeRosterChat(id: "roster-chat-1", laneId: "lane-roster-2"), makeRosterChat(id: "roster-chat-2", laneId: "lane-roster-1"), ]) @@ -480,6 +483,20 @@ final class WorkLiveRosterHydrationTests: XCTestCase { XCTAssertEqual(projection.sessions.map(\.id), ["local-chat", "roster-chat-1", "roster-chat-2"]) } + func testActiveProjectRosterOverlayExcludesKnownIdentityRowsFromLocalState() { + let lane = makeLane(id: "lane-cto", name: "CTO lane") + let ctoSession = makeSession(id: "cto-chat", laneId: lane.id, laneName: lane.name) + + let projection = overlayActiveProjectRoster( + localSessions: [ctoSession], + localLanes: [lane], + roster: nil, + identitySessionIds: Set([ctoSession.id]) + ) + + XCTAssertTrue(projection.sessions.isEmpty) + } + func testRosterNavigationUsesChatIdentityBeforeHintsAndScopedRepositoryBeforeBranch() { let ade = makeProject(id: "ade", name: "ADE") let versic = makeProject(id: "versic", name: "Versic") diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index f22425059..e283eceae 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -1243,7 +1243,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): - `rosterBuilder.ts` — builds the machine-wide all-projects session roster (`SyncRosterProject[]`) consumed by the Hub: agent chats, their attached shell rows, and **standalone CLI (tracked terminal) sessions — live and - ended**. The roster is built only from projects + ended**. Identity-bound chats (including the per-project CTO) and their + attached descendants are excluded from this ordinary roster; the optional + `identityKey` wire marker remains only as a defensive signal for stale or + legacy payloads. The roster is built only from projects whose registry `catalogVisibility` is `"recent"`, **plus the host's own project** (matched by `hostProjectId`) which is always included even if it is a `"system"`-visibility entry — so the machine you are actively hosting never @@ -2644,7 +2647,7 @@ payload. | File access | On-demand project/worktree file reads, listings, writes | iOS Files, desktop remote viewing | | Terminal stream/control | Subscribe to a logical-offset transcript snapshot plus live PTY output. The host installs a snapshot barrier before capture, queues concurrent data/exit events (256 events / 2 MB), trims overlap at UTF-8 boundaries, and recaptures up to four times when the snapshot did not reach the queued watermark; it closes instead of flushing a gap or unreconstructable overflow. Web/iOS clients drop duplicate ranges, trim overlap, and issue one guarded `sinceOffset` recovery subscribe when a live chunk starts beyond their watermark. A delta appends only the missing suffix; a full snapshot is authoritative replacement even when its end equals the current watermark. ACK-capable input uses stable `inputId`s and a bounded host dedupe ledger so reconnect/timeout retry cannot type twice; legacy hosts receive one-shot input with no ambiguous retry. Viewport resize remains subscription-scoped and the last desktop size is restored after the last mobile viewer detaches | iOS Work tab, hosted web Work terminal | | Chat stream | Agent chat transcript events plus subscribed byte-cursor scrollback. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session). The host carries sequence high-water marks through shared-listener rehydration and seeds a recreated buffer from the agent event sequence persisted in session metadata/transcript state, so it never reuses a `(sessionId, seq)` pair. The field remains optional and old clients keep working unchanged. `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to an authoritative snapshot. Optional live sends are marked delivered only after the WebSocket accepts the frame; a backpressured peer keeps its transcript offset in place and the pump stops at the first failed event so later chunks cannot overtake the missing one. A per-session hydration barrier blocks both the live broadcaster and transcript pump while a snapshot is captured. The pump resumes after the ack from the logical byte offset recorded before capture, so appends racing a slow snapshot arrive after the ack without a gap; snapshot overlap is removed by the normal delivery-key dedupe. The snapshot is a byte-capped tail: `chat_subscribe` also carries the client's `maxBytes`, and the host clamps the snapshot's `getChatEventHistory` budget to `min(host cap, maxBytes)` — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Modern acks also return `cursorKind: "byte"`, `tailStartOffset`, and authoritative `hasOlderHistory`. A host advertising `chatHistoryPaging` accepts `chat_history` only for an already-subscribed session and matching project/personal/foreign scope; it reads the same authorized transcript path without switching projects or booting a runtime. Transient failures return `unavailable: true` and preserve the requested cursor. Snapshot and older-page transcript reads use asynchronous filesystem/zlib work; same-session tail reads coalesce, while archived gzip inflations are globally admitted with only the active inflate and newest queued destination retained. Small archives use a bounded memory cache; a larger archive is inflated at most once into an unlinked, process-private temporary file under a 256 MiB logical-size/LRU budget and a temporary-volume free-space guard, after which pages are random-access disk reads. Request cancellation propagates through queued work, file reads, and inflates, so disconnected clients cannot leave expensive transcript jobs running. Both event-history paging and the legacy `chat.getTranscript` route use append-stable logical byte cursors; the latter advertises `cursorKind: "byte"` so clients do not treat an offset as a dense entry index. Hosted-web and iOS older pages are capped at 256 KiB and a failed read preserves its byte cursor for retry. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries `turnActive` from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint). The additive foreign-scope protocol remains available to controller reads, but iOS Hub taps activate the owning project before opening the chat. A `session_meta_updated` `chat_event` carrying a client's permission/interaction/mode change also rides this stream, so a mode switch made on one client (desktop ↔ iOS) patches every subscribed client's cached summary and composer controls live without a refetch | iOS Work tab, iOS Hub, controller chat | -| Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live **and** ended — so the mobile Hub renders every project's sessions at once **without activating each project**. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their session status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads `running`). `attentionCount` counts awaiting/failed **chat** rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry `toolType` so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Transcripts are excluded from the roster and load on demand after a row tap activates the owning project; the Hub cover exposes switching/hydration progress and an error with Retry instead of silently ignoring an unhydrated project. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | +| Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live **and** ended — so the mobile Hub renders every project's sessions at once **without activating each project**. Identity-bound chats (including each project's CTO) and all attached descendants are excluded from this ordinary roster; the optional `identityKey` marker lets clients reject stale or legacy leaked rows. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their session status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads `running`). `attentionCount` counts awaiting/failed **chat** rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry `toolType` so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Transcripts are excluded from the roster and load on demand after a row tap activates the owning project; the Hub cover exposes switching/hydration progress and an error with Retry instead of silently ignoring an unhydrated project. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, `work.listExternalSessions`, `work.importExternalSession`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project hub | | Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir/forget envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project hub | diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 4fc6e2dfc..3097fc9b6 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -457,9 +457,10 @@ publisher. What it filters and how: - **Identity chats are excluded.** A roster chat with an `identityKey` (CTO and - the other identity threads) never becomes an item. `rosterBuilder.ts` stamps - that label and the sync roster keeps carrying those rows for the mobile hub; - only this feed drops them, mirroring the desktop sidebar. + the other identity threads) never becomes an item. The machine-wide sync + roster now omits those rows and their attached descendants before publishing; + this defensive filter remains for stale or legacy roster payloads, mirroring + the desktop sidebar and keeping the separate CTO surface out of Activity. - **Child shells fold into their parent.** A roster chat whose parent chat is itself in the roster is dropped — a shell attached to a visible chat is one piece of work, and publishing 1 + N items per chat inflated every count.