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
15 changes: 13 additions & 2 deletions apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
}
Expand Down
80 changes: 77 additions & 3 deletions apps/ios/ADE/Views/Work/WorkChatSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions apps/ios/ADE/Views/Work/WorkRootScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ struct WorkRootScreen: View {
}
}
.listStyle(.plain)
.listSectionSpacing(.compact)
.listSectionSpacing(.custom(Self.workGroupSectionSpacing))
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.interactively)
.contentMargins(.bottom, workRootBottomTabBarScrollMargin, for: .scrollContent)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "")
Expand Down
6 changes: 4 additions & 2 deletions apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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: {
Expand Down
87 changes: 44 additions & 43 deletions apps/ios/ADE/Views/Work/WorkSessionRowCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down
Loading