From 8abd33c8dc9e4abad070e9945e887a2241b140ab Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:12:52 -0400 Subject: [PATCH] fix(ios): polish mobile Work session cards --- .../Views/Work/WorkChatRichCardViews.swift | 15 +++- .../ADE/Views/Work/WorkChatSessionView.swift | 80 ++++++++++++++++- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 12 ++- .../WorkSessionDestinationView+Actions.swift | 13 +++ .../Work/WorkSessionDestinationView.swift | 6 +- .../ADE/Views/Work/WorkSessionRowCard.swift | 87 ++++++++++--------- 6 files changed, 159 insertions(+), 54 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 7457dcd1f..8ffad0a4a 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2496,17 +2496,28 @@ struct WorkSubagentActivePopup: View { let count: Int let onOpen: () -> Void + private var label: String { + count == 1 ? "Subagent" : "Subagents" + } + var body: some View { WorkComposerBadgeCapsule( tint: ADEColor.accent, spacing: 8, - accessibilityLabel: "\(count) subagent\(count == 1 ? "" : "s")", + accessibilityLabel: "\(count) \(label.lowercased())", onOpen: onOpen ) { Image(systemName: "person.2.fill") .font(.system(size: 12, weight: .semibold)) - Text("\(count) subagent\(count == 1 ? "" : "s")") + Text(label) .font(.caption.weight(.semibold)) + if count > 1 { + Text("\(count)") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.14), in: Capsule(style: .continuous)) + } } } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index d8cc12748..e9de85203 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -357,6 +357,10 @@ struct WorkChatSessionView: View { let onSelectEffort: @MainActor (String) async -> Void let onSelectCodexFastMode: @MainActor (Bool) async -> Bool + /// Opens the parent chat for a standalone spawned child session. The nested + /// transcript viewer uses its own back control and leaves this unset. + var onOpenParentSession: (() -> Void)? = nil + var resolvedSessionStatus: String? = nil var lanes: [LaneSummary] = [] var lanesRenderSignature: Int = 0 @@ -920,6 +924,17 @@ struct WorkChatSessionView: View { // so the Accept / Decline actions are always in reach instead of scrolled // off the top of the transcript. + if let parentId = chatSummaryContext.orchestrationParentSessionId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty, + parentId != session.id, + let onOpenParentSession { + WorkSubagentLineageBreadcrumb( + parentTitle: chatSummaryContext.parentTitle, + onOpen: onOpenParentSession + ) + } + // Connection-caused failures are communicated via the top-right gear, but // cached/offline chat actions still need their own visible errors. if let errorMessageSnapshot, !hostUnreachable { @@ -1106,15 +1121,25 @@ struct WorkChatSessionView: View { let subagentCount = subagentSnapshots.count let activeScheduledWorkCount = workScheduledWorkActiveCount(scheduledWorkSnapshots) let showsChatInfoBadge = inputLockMessage == nil && activeScheduledWorkCount > 0 && onOpenChatInfo != nil - let showsSubagentBadge = inputLockMessage == nil && subagentCount > 0 && onOpenSubagents != nil + let showsSubagentBadge = inputLockMessage == nil + && subagentCount > 0 + && onOpenSubagents != nil let showsPrBadge = inputLockMessage == nil && prBadge != nil && onOpenPrDetails != nil if showsChatInfoBadge || showsSubagentBadge || showsPrBadge { HStack(spacing: 8) { if showsChatInfoBadge, let onOpenChatInfo { WorkChatInfoActivePopup(count: activeScheduledWorkCount, onOpen: onOpenChatInfo) } - if showsSubagentBadge, let onOpenSubagents { - WorkSubagentActivePopup(count: subagentCount, onOpen: onOpenSubagents) + if showsSubagentBadge { + if subagentCount == 1, + let snapshot = subagentSnapshots.first, + let onSelectSubagentRow { + WorkSubagentActivePopup(count: subagentCount) { + Task { await onSelectSubagentRow(snapshot) } + } + } else if let onOpenSubagents { + WorkSubagentActivePopup(count: subagentCount, onOpen: onOpenSubagents) + } } if showsPrBadge, let prBadge, let onOpenPrDetails { WorkChatPrActivePopup(badge: prBadge, onOpen: onOpenPrDetails) @@ -2925,6 +2950,55 @@ private struct WorkSubagentTakeoverBanner: View { } } +private struct WorkSubagentLineageBreadcrumb: View { + let parentTitle: String? + let onOpen: () -> Void + + private var sourceLabel: String { + let trimmed = parentTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "parent chat" : trimmed + } + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 8) { + Image(systemName: "arrow.turn.up.left") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + + VStack(alignment: .leading, spacing: 1) { + Text("Subagent chat") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + Text("from \(sourceLabel)") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 4) + + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.accent.opacity(0.18), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Open parent chat, \(sourceLabel)") + .accessibilityHint("Returns to the chat that spawned this subagent.") + } +} + private struct WorkQueueRecoveryBanner: View { let recovery: WorkQueueRecoveryModel let restoring: Bool diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 11cf40474..d9e5998ae 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -628,7 +628,7 @@ struct WorkRootScreen: View { } } .listStyle(.plain) - .listSectionSpacing(.compact) + .listSectionSpacing(.custom(Self.workGroupSectionSpacing)) .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.interactively) .contentMargins(.bottom, workRootBottomTabBarScrollMargin, for: .scrollContent) @@ -957,6 +957,11 @@ struct WorkRootScreen: View { /// drawn in that gutter, so it costs no card width of its own. private static let workLaneRailGutter: CGFloat = 9 + /// One deliberate gap between adjacent lane/status sections. Card-to-card + /// breathing room stays inside each row; this value owns only the transition + /// between groups, including a headerless singleton and the next grouped lane. + private static let workGroupSectionSpacing: CGFloat = 16 + /// Absolute leading indent of a nested child-shell block, measured from the /// list's own 16pt margin. Held constant whether or not a lane rail is present /// so a nested shell never reads as double-indented under the rail. @@ -1009,7 +1014,6 @@ struct WorkRootScreen: View { private func workSessionGroupSectionWithHeader(_ group: WorkSessionGroup) -> some View { let isLaneDeleting = group.laneId.map(syncService.pendingLaneDeletionIds.contains) ?? false let collapsed = workGroupIsCollapsed(group) - let isQuietRow = group.isQuiet && collapsed // Real lane sections get the accent rail; status/time headers span multiple // lanes, so a single lane color would be a lie there. let railColor: Color? = group.laneId == nil @@ -1082,9 +1086,9 @@ struct WorkRootScreen: View { .listRowBackground(ADEColor.pageBackground) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets( - top: isQuietRow ? 2 : 8, + top: 0, leading: 16, - bottom: isQuietRow ? 0 : 2, + bottom: 0, trailing: 16 )) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index a369ba9fd..643a9620d 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -839,6 +839,19 @@ extension WorkSessionDestinationView { syncService.requestedLaneNavigation = LaneNavigationRequest(laneId: laneId) } + /// Routes a standalone spawned chat back to its parent through the same + /// Work navigation request used by deeplinks and cross-surface opens. + func openParentSession() { + guard let parentId = composerChatSummary?.orchestrationParentSessionId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty + else { return } + syncService.requestedWorkSessionNavigation = WorkSessionNavigationRequest( + sessionId: parentId, + laneId: (session ?? initialSession)?.laneId + ) + } + @MainActor func presentSessionRename() { sessionActionRenameText = (chatSummary?.title ?? session?.title ?? initialSession?.title ?? "") diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 74347ed92..a9a56ed78 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -1541,6 +1541,7 @@ struct WorkSessionDestinationView: View { inputLockMessage: inputLockMessage, transitionNamespace: transitionNamespace, onOpenLane: openLaneAction, + onOpenParentSession: viewingSubagent ? nil : { openParentSession() }, onSend: { text, attachments, mode in await sendMessage(text, attachments: attachments, deliveryMode: mode) }, @@ -1577,8 +1578,9 @@ struct WorkSessionDestinationView: View { scheduledWorkSnapshotsRenderSignature: viewingSubagent ? 0 : workScheduledWorkSnapshotsRenderSignature(scheduledWorkSnapshots), selectedSubagentTaskId: subagentView?.taskId, onOpenChatInfo: viewingSubagent ? nil : { Task { await prepareChatInfoPresentation() } }, - // The subagent roster now lives inside the unified Chat Info sheet, so the - // composer badge and header menu both open Chat Info — no separate drawer. + // A singular badge opens its child directly; an aggregate badge opens + // Chat Info. Timeline-row selection stays scoped to the parent chat, so + // nested transcript state cannot accidentally fetch from itself. onOpenSubagents: viewingSubagent ? nil : { Task { await prepareChatInfoPresentation() } }, onSelectSubagentRow: subagentRowSelectionHandler(viewingSubagent: viewingSubagent), onForkChatInLane: { diff --git a/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift b/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift index b2736a3c6..c769c1a03 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift @@ -181,6 +181,7 @@ private struct WorkSessionRowRenderSignature: Equatable { /// that want a human stand out with no banner at all. let isProminent: Bool let model: String? + let isSubagent: Bool let showsLaneIdentity: Bool let settledAt: String? let statusNote: String? @@ -265,6 +266,7 @@ private struct WorkSessionRowRenderSignature: Equatable { self.isProminent = row.status?.prominent ?? false self.statusTone = row.status?.tone self.model = chatSummary?.model + self.isSubagent = chatSummary?.spawnKind == .subagent self.showsLaneIdentity = showsLaneIdentity self.settledAt = session.settledAt self.statusNote = session.statusNote @@ -371,6 +373,14 @@ struct WorkSessionRow: View, Equatable { size: 20 ) + if showsSubagentIdentity { + Image(systemName: "person.2.fill") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .fixedSize() + .accessibilityHidden(true) + } + Text(chatSummary?.title ?? session.title) .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) @@ -427,23 +437,12 @@ struct WorkSessionRow: View, Equatable { } /// Leading chrome glyphs, the lane and its git state (headerless rows only), - /// the floor, then the one status slot hard against the trailing edge. - /// - /// **Layout contract.** Exactly two children claim width: the lane chip when - /// it is present, and the floor label's flexible frame. Everything else — - /// including the status slot — is `.fixedSize()`, and an inflexible child is - /// sized before a flexible one. THAT is what guarantees the invariant that - /// matters: the status slot never truncates, never drops, and never degrades - /// to a glyph. (Note the floor label is `.fixedSize()` AND flexible-framed: it - /// takes its ideal width and the frame only holds the remaining space open, so - /// it pushes the slot to the trailing edge without ever competing for it.) + /// subagent identity, then the one status slot hard against the trailing edge. /// - /// Do not "fix" this by adding `layoutPriority` to the lane chip: a - /// higher-priority child is offered the entire remaining width first, which - /// would let a long lane name starve the status word. Do not reach for - /// `ViewThatFits` either — a candidate containing a `Spacer` reports an ideal - /// width that always fits, so the ladder would never advance past its first - /// rung. + /// The leading identity cluster is the only flexible child. Its lane text + /// tail-truncates inside the width left after the fixed git/subagent/status + /// affordances are measured, so the lane remains readable without allowing a + /// long name to starve the status word. private var lineOne: some View { HStack(spacing: 5) { if session.pinned { @@ -460,24 +459,28 @@ struct WorkSessionRow: View, Equatable { .foregroundStyle(ADEColor.textMuted) .fixedSize() } - if showsLaneIdentity { - laneChip - laneGitState + HStack(spacing: 5) { + if showsLaneIdentity { + laneChip + laneGitState + } + if showsSubagentIdentity { + Image(systemName: "person.2.fill") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .fixedSize() + .accessibilityHidden(true) + } } - // Always rendered, even when empty: its flexible frame is what holds the - // status slot against the trailing edge. `.lineLimit` / `.truncationMode` - // are deliberately absent — `.fixedSize()` gives this label its ideal - // width, so there is nothing left for them to do. - Text(rowFloorLabel) - .font(.caption2.monospacedDigit()) - .foregroundStyle(ADEColor.textMuted) - .fixedSize() - .frame(maxWidth: .infinity, alignment: .leading) - Spacer(minLength: 4) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) statusSlot(wraps: false) } } + private var showsSubagentIdentity: Bool { + renderSignature.isSubagent + } + private var lineTwo: some View { HStack(spacing: 6) { titleView(lineLimit: 1) @@ -560,6 +563,13 @@ struct WorkSessionRow: View, Equatable { previewView(lineLimit: 2) } + if showsSubagentIdentity { + Image(systemName: "person.2.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .accessibilityHidden(true) + } + if showsLaneIdentity { laneChip laneGitState @@ -635,6 +645,7 @@ struct WorkSessionRow: View, Equatable { .lineLimit(1) .truncationMode(.tail) } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } /// Dirty / ahead / behind for the lane, on `showsLaneIdentity` rows only. @@ -649,7 +660,7 @@ struct WorkSessionRow: View, Equatable { /// and this can never print on every row of a lane again. /// /// Muted and `.fixedSize()`: this is context, not a call to action, and line 1 - /// still owes its elastic width to the floor label alone. Nothing is drawn + /// still owes its elastic width to the leading identity cluster. Nothing is drawn /// when the worktree is clean and level — an always-present "0 ahead, 0 /// behind" would be chrome. Colour never carries the meaning: the dot is /// accompanied by the arrow counts, and VoiceOver gets the words. @@ -718,19 +729,6 @@ struct WorkSessionRow: View, Equatable { session.manuallyNamed != true } - /// Line 1 is never allowed to be empty — an empty leading edge reads as a - /// half-loaded row. The model comes first because on a phone it is the only - /// thing that distinguishes two rows on the same provider; the compact - /// timestamp is the fallback. - private var rowFloorLabel: String { - let model = shortModelLabel(renderSignature.model) - if !model.isEmpty { return model } - // With no status word the slot itself renders the timestamp, so repeating it - // here would print the same "12m" twice on one line. - if renderSignature.statusLabel == nil { return "" } - return relativeTimestampCompact(renderSignature.activityTimestamp) - } - /// The row's ONE status slot. When there is no status word — settled, and only /// settled — the slot renders the compact timestamp instead, which is the same /// contract as desktop's `SessionStatusSlot` timestamp fallback: down in the @@ -852,6 +850,9 @@ struct WorkSessionRow: View, Equatable { if let statusLabel = renderSignature.statusLabel { parts.append(statusLabel) } + if renderSignature.isSubagent { + parts.append("subagent") + } if let model = renderSignature.model, !model.isEmpty { parts.append(shortModelLabel(model)) }