From b5f3c9014232ede23753e18feee835ccf85a10c0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:34:35 -0400 Subject: [PATCH 1/5] fix(chat): provider-only handoff chip, floating mobile badges, pinned lane picker Emit the model handoff event only when the top-level ADE provider group changes. A model change inside one provider (Claude Opus -> Claude Fable, or two vendors inside OpenCode) no longer creates a chip. iOS: render the handoff as logo -> arrow -> logo, the same as desktop. Float the chat-info and PR badges over the transcript so the thread scrolls behind them. Pin the lane picker on the new-chat screen, collapse the header in tiers as the composer grows, and present the lane menu as a sheet with larger text. Co-Authored-By: Claude Fable 5.1 --- .../services/chat/agentChatService.test.ts | 28 ++++ .../main/services/chat/agentChatService.ts | 11 +- .../Work/WorkChatHeaderAndMessageViews.swift | 52 +++++++ .../Work/WorkChatSessionView+Timeline.swift | 2 + .../ADE/Views/Work/WorkChatSessionView.swift | 135 +++++++++++++---- .../ios/ADE/Views/Work/WorkEventMapping.swift | 7 +- .../Views/Work/WorkLanePickerDropdown.swift | 102 +++++++------ apps/ios/ADE/Views/Work/WorkModels.swift | 25 ++++ .../ADE/Views/Work/WorkNewChatScreen.swift | 141 ++++++++++++++---- apps/ios/ADE/Views/Work/WorkPreviews.swift | 20 +++ .../ADE/Views/Work/WorkTimelineHelpers.swift | 23 +++ .../ADE/Views/Work/WorkTranscriptParser.swift | 14 +- apps/ios/ADETests/ADETests.swift | 47 +++++- 13 files changed, 500 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 6169a105f8..37c6246587 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4404,6 +4404,34 @@ describe("createAgentChatService", () => { }); }); + it("does not record a handoff when the model switch stays inside one provider", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const created = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.updateSession({ + sessionId: created.id, + modelId: "anthropic/claude-opus-5" as never, + }); + + // The chip means "a different agent picked this thread up". Opus and + // Sonnet are the same agent, so emitting a handoff here produced the + // nonsense card "Claude -> Claude" in the transcript. + const summary = await service.getSessionSummary(created.id); + expect(summary?.provider).toBe("claude"); + expect(summary?.modelId).toBe("anthropic/claude-opus-5"); + expect(summary?.modelHandoffHistory ?? []).toEqual([]); + expect(events.map((event) => event.event)).not.toContainEqual( + expect.objectContaining({ type: "model_handoff" }), + ); + }); + it("refuses a model switch onto a provider that cannot carry the injected servers", async () => { const { service } = createService(); const created = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d15031661d..e809a2aa6f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -48752,7 +48752,16 @@ export function createAgentChatService(args: { previousProvider !== nextProvider || managed.session.modelId !== descriptor.id || managed.session.model !== nextModel; - if (modelChanged) { + // A handoff chip marks a change of *agent*, not a change of model. Only + // a different top-level ADE provider group qualifies: claude -> codex is + // a handoff, Claude Opus -> Claude Fable is not. Aggregator providers + // (opencode, cursor, droid) collapse to a single group, so switching the + // model they front (anthropic -> openai inside OpenCode) is also not a + // handoff. `modelChanged` stays broader on purpose — it still drives the + // runtime teardown and title re-adoption below, which any model switch + // needs. + const providerChanged = previousProvider !== nextProvider; + if (providerChanged) { modelHandoff = { fromProvider: previousProvider, toProvider: nextProvider, diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 95b4d89650..f4a554595d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -1256,6 +1256,58 @@ struct WorkTurnSeparatorView: View { } } +/// Provider handoff divider: the transcript's "a different agent picked this +/// thread up" marker. Mirrors desktop `AgentChatMessageList` — hairline, the +/// outgoing provider's logo, a small uppercase "handoff" label, an arrow, the +/// incoming provider's logo, hairline. Model swaps *inside* one provider are +/// not handoffs and never reach here. +struct WorkModelHandoffDivider: View { + let card: WorkEventCardModel + + var body: some View { + let fromProvider = card.metadata.first + let toProvider = card.metadata.count > 1 ? card.metadata[1] : nil + if let fromProvider, let toProvider, fromProvider != toProvider { + HStack(spacing: 10) { + hairline + HStack(spacing: 8) { + providerMark(fromProvider) + Text("Handoff") + .font(.system(size: 10, weight: .semibold)) + .textCase(.uppercase) + .tracking(1.4) + .foregroundStyle(ADEColor.textMuted) + Image(systemName: "arrow.right") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + providerMark(toProvider) + } + hairline + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(card.title) + } + } + + private func providerMark(_ provider: String) -> some View { + WorkProviderBareLogo( + provider: provider, + fallbackSymbol: "terminal.fill", + tint: ADEColor.textMuted, + size: 15 + ) + .opacity(0.9) + } + + private var hairline: some View { + Rectangle() + .fill(ADEColor.glassBorder) + .frame(height: 0.6) + } +} + struct WorkTurnEndMarkerView: View { let marker: WorkTurnEndMarker var toolCount: Int = 0 diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index c6e4ce6fec..40c1121b7e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -408,6 +408,8 @@ extension WorkChatSessionView { enabled: isLive, onRecover: onRecoverCodexTurn ) + } else if card.kind == "modelHandoff" { + WorkModelHandoffDivider(card: card) } else if card.kind == "turnDiagnostics" { WorkTurnDiagnosticsDisclosureView( card: card, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 31fd9aaaef..fc5d5fc139 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -22,6 +22,10 @@ let workChatSubagentActivePopupHeight: CGFloat = 34 /// target, so the row that holds them has to be 44 too — pinning it to 34 was /// what squeezed the PR chip's label into an ellipsis. let workChatComposerChipRowHeight: CGFloat = 44 +/// Gap between the floating badge chip row and the bottom of the transcript +/// viewport. The row floats over the thread instead of sitting in the +/// composer, so this is pure visual breathing room. +let workChatFloatingBadgeRowBottomPadding: CGFloat = 12 let workChatOlderHistoryTriggerDistance: CGFloat = 240 let workChatOlderHistoryRearmDistance: CGFloat = 420 let workChatOlderHistoryScrollableDistance: CGFloat = 1 @@ -1373,7 +1377,64 @@ struct WorkChatSessionView: View { } var jumpToLatestPillBottomPadding: CGFloat { - 16 + // The badge chip row floats over the same bottom-left corner of the + // transcript. Stack the pill above it so both stay fully visible. + guard showsComposerBadgeChips else { return 16 } + return workChatFloatingBadgeRowBottomPadding + workChatComposerChipRowHeight + 8 + } + + /// How many items the Chat Info sheet would show. Drives both the badge's + /// count and whether it exists at all. + var composerBadgeChatInfoCount: Int { + workChatInfoItemCount( + subagents: subagentSnapshots, + scheduledWork: scheduledWorkSnapshots + ) + } + + var showsComposerChatInfoBadge: Bool { + inputLockMessage == nil && composerBadgeChatInfoCount > 0 && onOpenChatInfo != nil + } + + var showsComposerPrBadge: Bool { + inputLockMessage == nil && prBadge != nil && onOpenPrDetails != nil + } + + /// Whether the floating badge row is on screen. Also reserves transcript + /// tail space so the last message can still scroll clear of the chips. + var showsComposerBadgeChips: Bool { + showsComposerChatInfoBadge || showsComposerPrBadge + } + + /// Chat-info / PR badges. These used to be a fixed 44pt row inside + /// `composerInset`, which cost the thread that much height on every chat + /// that had a badge. They now float over the transcript like the + /// "jump to latest" pill, so the thread scrolls behind them. + @ViewBuilder + var composerBadgeChipRow: some View { + let chatInfoCount = composerBadgeChatInfoCount + let chips = HStack(spacing: 8) { + if showsComposerChatInfoBadge, let onOpenChatInfo { + WorkChatInfoActivePopup(count: chatInfoCount, onOpen: onOpenChatInfo) + } + if showsComposerPrBadge, let prBadge, let onOpenPrDetails { + WorkChatPrActivePopup(badge: prBadge, onOpen: onOpenPrDetails) + } + } + // Today's two chips always fit, and a plain HStack leaves the rest of the + // row non-interactive — important now that the row floats over the + // transcript, since a full-width horizontal ScrollView would swallow + // vertical drags in that band. A future chip that overflows still gets the + // scroller (and then the band is genuinely its own). + ViewThatFits(in: .horizontal) { + chips + ScrollView(.horizontal, showsIndicators: false) { + chips.padding(.trailing, 8) + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: workChatComposerChipRowHeight, alignment: .leading) } var maxUserBubbleWidth: CGFloat? { @@ -1591,32 +1652,9 @@ struct WorkChatSessionView: View { WorkClaudeGoalPill(goal: claudeGoal) } - // One chip per destination. Subagents used to get their own capsule that - // opened the very same sheet as Chat Info; the count now covers both. - let chatInfoCount = workChatInfoItemCount( - subagents: subagentSnapshots, - scheduledWork: scheduledWorkSnapshots - ) - let showsChatInfoBadge = inputLockMessage == nil && chatInfoCount > 0 && onOpenChatInfo != nil - let showsPrBadge = inputLockMessage == nil && prBadge != nil && onOpenPrDetails != nil - if showsChatInfoBadge || showsPrBadge { - // Horizontally scrollable so a future chip can never truncate the ones - // beside it — it just scrolls out of reach instead. - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - if showsChatInfoBadge, let onOpenChatInfo { - WorkChatInfoActivePopup(count: chatInfoCount, onOpen: onOpenChatInfo) - } - if showsPrBadge, let prBadge, let onOpenPrDetails { - WorkChatPrActivePopup(badge: prBadge, onOpen: onOpenPrDetails) - } - } - .padding(.trailing, 8) - } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: workChatComposerChipRowHeight, alignment: .leading) - } + // Chat-info / PR badges no longer live here: they float over the + // transcript (see `composerBadgeChipRow`) so the thread scrolls behind + // them instead of losing 44pt of height to a fixed composer row. if !pendingSteers.isEmpty { WorkQueuedSteerStrip( @@ -1796,7 +1834,16 @@ struct WorkChatSessionView: View { streamingStatusSection Color.clear - .frame(height: workChatContentBottomGutterHeight + workChatBottomAnchorSpacerHeight) + .frame( + height: workChatContentBottomGutterHeight + + workChatBottomAnchorSpacerHeight + // The badge chips float over the transcript, so the tail has + // to reserve their height or the last message hides behind + // them. + + (showsComposerBadgeChips + ? workChatComposerChipRowHeight + workChatFloatingBadgeRowBottomPadding + : 0) + ) .id("chat-end") .transaction { transaction in transaction.animation = nil @@ -1954,6 +2001,38 @@ struct WorkChatSessionView: View { private func chatColumn(proxy: ScrollViewProxy) -> some View { VStack(spacing: 0) { transcriptScrollView(proxy: proxy) + .overlay(alignment: .bottomLeading) { + // Floats over the thread instead of consuming composer height, so + // the transcript scrolls behind the badges (same treatment as the + // "jump to latest" pill, which stacks above these). + Group { + if showsComposerBadgeChips { + composerBadgeChipRow + .padding(.horizontal, 16) + .padding(.bottom, workChatFloatingBadgeRowBottomPadding) + .background(alignment: .bottom) { + // Short scrim so capsule text stays legible over prose the + // chips are now floating on top of. + LinearGradient( + colors: [ + workChatCanvasBackground.opacity(0), + workChatCanvasBackground.opacity(0.9) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame( + height: workChatComposerChipRowHeight + + workChatFloatingBadgeRowBottomPadding + + 20 + ) + .allowsHitTesting(false) + } + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.18), value: showsComposerBadgeChips) + } composerInset(proxy: proxy) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 284024c337..6457fa4a31 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -684,10 +684,13 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { // this decoder has no envelope context. return .adeCard(card) case .modelHandoff(let fromProvider, let toProvider, _, _, let turnId): + // Rides the notice channel, but with its own kind and the provider pair in + // `detail` so `buildWorkEventCards` can paint the desktop-style + // logo → logo divider. The message stays the VoiceOver label. return .systemNotice( - kind: "info", + kind: workModelHandoffNoticeKind, message: workModelHandoffNoticeMessage(fromProvider: fromProvider, toProvider: toProvider), - detail: nil, + detail: workModelHandoffNoticeDetail(fromProvider: fromProvider, toProvider: toProvider), turnId: turnId, steerId: nil ) diff --git a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift index 31544e2be7..839a98e18b 100644 --- a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift +++ b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift @@ -16,6 +16,10 @@ struct WorkLanePickerDropdown: View { var laneSubtitle: ((LaneSummary) -> String?)? = nil var isLaneDisabled: ((LaneSummary) -> Bool)? = nil var onRefresh: (@MainActor () async -> Void)? = nil + /// Fires with the presentation state of the lane sheet. Callers that own a + /// composer use it to park and restore keyboard focus around the sheet. + /// Declared last with a default so existing call sites compile unchanged. + var onMenuPresentationChange: ((Bool) -> Void)? = nil @State private var menuPresented = false @State private var searchQuery = "" @@ -63,7 +67,11 @@ struct WorkLanePickerDropdown: View { .buttonStyle(.plain) .accessibilityLabel("Select lane") .accessibilityValue(triggerTitle) - .popover(isPresented: $menuPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + // A sheet, not a popover: UIKit compresses a popover into whatever space + // the keyboard and a grown composer leave behind, which clipped the lane + // list to a few rows. A sheet owns its own space and resizes for the + // keyboard instead. + .sheet(isPresented: $menuPresented) { WorkLanePickerMenu( lanes: filteredLanes, allLanesEmpty: lanes.isEmpty, @@ -78,11 +86,12 @@ struct WorkLanePickerDropdown: View { searchQuery = "" } ) - .frame(width: 280) - .presentationCompactAdaptation(.popover) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } .onChange(of: menuPresented) { _, isOpen in if !isOpen { searchQuery = "" } + onMenuPresentationChange?(isOpen) } if let onRefresh { @@ -180,7 +189,10 @@ struct WorkLanePickerDropdown: View { } } -private struct WorkLanePickerMenu: View { +/// Sheet body for the lane picker. Sized to fill its presentation container — +/// no fixed width or list height — so the keyboard can only shrink the scroll +/// area, never clip it away. +struct WorkLanePickerMenu: View { let lanes: [LaneSummary] let allLanesEmpty: Bool let selectedLaneId: String @@ -194,19 +206,21 @@ private struct WorkLanePickerMenu: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: 6) { + HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) TextField("Search lanes...", text: $searchQuery) .textFieldStyle(.plain) - .font(.system(size: 11)) + .font(.system(size: 16)) .foregroundStyle(ADEColor.textPrimary) .focused($searchFocused) .submitLabel(.done) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) } - .padding(.horizontal, 10) - .frame(height: 32) + .padding(.horizontal, 12) + .frame(height: 44) .overlay(alignment: .bottom) { Rectangle() .fill(ADEColor.border.opacity(0.35)) @@ -217,46 +231,49 @@ private struct WorkLanePickerMenu: View { Button { onSelect(workAutoCreateLaneSentinelId) } label: { - HStack(spacing: 6) { - WorkOrchestratorRainbowText(text: "Auto-create lane") + HStack(spacing: 8) { + WorkOrchestratorRainbowText(text: "Auto-create lane", size: 16) if selectedLaneId == workAutoCreateLaneSentinelId { Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(ADEColor.accent) } } .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ADEColor.border.opacity(0.25)) + .frame(height: 0.5) + } } ScrollView { - LazyVStack(spacing: 0) { + LazyVStack(spacing: 2) { if lanes.isEmpty { Text(allLanesEmpty ? "No lanes available" : "No lanes found") - .font(.system(size: 11)) + .font(.system(size: 15)) .foregroundStyle(ADEColor.textMuted) .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .padding(.vertical, 20) } else { ForEach(lanes) { lane in laneRow(lane) } } } - .padding(4) + .padding(.vertical, 6) } - .frame(maxHeight: 260) + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .background(ADEColor.cardBackground.opacity(0.96)) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 0.8) - ) - .shadow(color: Color.black.opacity(0.45), radius: 16, y: 8) + .padding(.horizontal, 16) + .padding(.top, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(ADEColor.pageBackground.ignoresSafeArea()) .onAppear { searchFocused = true } @@ -273,51 +290,51 @@ private struct WorkLanePickerMenu: View { guard !disabled else { return } onSelect(lane.id) } label: { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - WorkLaneLogoMark(color: laneColor, laneIcon: lane.icon, size: 12) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + WorkLaneLogoMark(color: laneColor, laneIcon: lane.icon, size: 16) .opacity(disabled ? 0.45 : 1) Text(lane.name) - .font(.system(size: 11, weight: isSelected ? .medium : .regular)) + .font(.system(size: 16, weight: isSelected ? .semibold : .regular)) .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.textPrimary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) if disabled { Image(systemName: "lock.fill") - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(ADEColor.warning.opacity(0.85)) } else if isSelected { Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 15, weight: .bold)) .foregroundStyle(ADEColor.accent) } } if !branch.isEmpty { - HStack(spacing: 4) { + HStack(spacing: 5) { Image(systemName: "arrow.branch") - .font(.system(size: 10, weight: .regular)) + .font(.system(size: 12, weight: .regular)) .foregroundStyle(ADEColor.textMuted.opacity(0.6)) Text(branch) - .font(.system(size: 10)) + .font(.system(size: 13)) .foregroundStyle(ADEColor.textMuted.opacity(0.92)) .lineLimit(1) } - .padding(.leading, 18) + .padding(.leading, 24) } if let eligibilitySubtitle, !eligibilitySubtitle.isEmpty { Text(eligibilitySubtitle) - .font(.system(size: 10)) + .font(.system(size: 13)) .foregroundStyle(disabled ? ADEColor.warning.opacity(0.9) : ADEColor.textSecondary) .lineLimit(2) - .padding(.leading, 18) + .padding(.leading, 24) } } - .padding(.horizontal, 8) - .padding(.vertical, branch.isEmpty && eligibilitySubtitle == nil ? 6 : 5) + .padding(.horizontal, 10) + .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) + in: RoundedRectangle(cornerRadius: 10, style: .continuous) ) .contentShape(Rectangle()) } @@ -329,6 +346,7 @@ private struct WorkLanePickerMenu: View { /// Desktop `ade-orchestrator-rainbow-text` gradient label for auto-create lane. private struct WorkOrchestratorRainbowText: View { let text: String + var size: CGFloat = 11 private static let colors: [Color] = [ Color(red: 1.0, green: 0.37, blue: 0.37), @@ -341,7 +359,7 @@ private struct WorkOrchestratorRainbowText: View { var body: some View { Text(text) - .font(.system(size: 11, weight: .medium)) + .font(.system(size: size, weight: .medium)) .foregroundStyle( LinearGradient(colors: Self.colors, startPoint: .leading, endPoint: .trailing) ) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 41803d17bc..72b89c2040 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -261,6 +261,31 @@ func workModelHandoffNoticeMessage(fromProvider: String, toProvider: String) -> return "Model handoff · \(from) → \(to)" } +/// Notice kind reserved for a provider handoff. A handoff rides the notice +/// channel so no exhaustive `WorkChatEvent` switch has to grow a case, but it +/// is lifted out of the generic notice card in `buildWorkEventCards` and drawn +/// as the logo → logo divider instead of a text ribbon. +let workModelHandoffNoticeKind = "model_handoff" + +/// Packs the two provider ids into the notice `detail` field. Provider ids are +/// slugs (`claude`, `codex`, `opencode`, …) so the pipe is unambiguous, and the +/// notice payload stays a plain `String?` — no model change needed. +func workModelHandoffNoticeDetail(fromProvider: String, toProvider: String) -> String { + "\(fromProvider)|\(toProvider)" +} + +/// Inverse of `workModelHandoffNoticeDetail`. Returns nil when either half is +/// missing so a malformed row is dropped rather than drawn half-empty. +func workModelHandoffProviders(fromDetail detail: String?) -> (from: String, to: String)? { + guard let detail else { return nil } + let parts = detail.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let from = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let to = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !from.isEmpty, !to.isEmpty else { return nil } + return (from, to) +} + func workChatPendingInputHeaderVerb(source: String?, fallbackProvider: String?, kind: String) -> String { let rawSource = source?.trimmingCharacters(in: .whitespacesAndNewlines) let provider = rawSource?.isEmpty == false ? rawSource : fallbackProvider diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 61d9be6bb3..61f1dcf32a 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -576,6 +576,47 @@ private func workReplacingRegexMatches( return result } +/// Progressive-disclosure tiers for the new-chat header. The screen picks one +/// from the measured height left for the scrollable header after the pinned +/// lane picker and the (growable) composer have taken their space — never from +/// keyboard notifications, so composer growth and the keyboard collapse the +/// header the same way. +enum WorkNewChatHeaderTier: Int, Comparable { + /// Nothing but the pinned rows fit. + case hidden = 0 + /// Action chips only. + case minimal = 1 + /// Action chips + usage carousel. + case compact = 2 + /// Word-mark, tagline, chips, usage carousel. + case full = 3 + + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + + var showsBranding: Bool { self == .full } + var showsUsageCarousel: Bool { self >= .compact } + var showsActionChips: Bool { self >= .minimal } + + /// Minimum scroll-area height each tier needs, richest first. + private static let thresholds: [(tier: Self, minHeight: CGFloat)] = [ + (.full, 300), + (.compact, 190), + (.minimal, 96), + ] + + /// Extra height required to step *up* a tier, so revealing content that then + /// re-consumes the height cannot flip the tier back and forth. + private static let hysteresis: CGFloat = 24 + + static func resolve(available: CGFloat, current: Self) -> Self { + for entry in thresholds { + let bound = entry.tier > current ? entry.minHeight + hysteresis : entry.minHeight + if available >= bound { return entry.tier } + } + return .hidden + } +} + /// Full-screen "Start a new conversation" composer that replaces the modal /// WorkNewChatSheet. Mirrors the desktop welcome screen: big ADE word-mark, /// one-line tagline, a minimal workspace pill users can change inline, and a @@ -620,6 +661,15 @@ struct WorkNewChatScreen: View { /// Status banner shown above the composer while an auto-created lane is being /// minted before the chat/CLI session starts. @State private var autoCreateStatus: String? + /// Progressive header collapse driven by the measured height left for the + /// scroll area, not by keyboard notifications, so a grown composer collapses + /// the header exactly like the keyboard does. + @State private var headerTier: WorkNewChatHeaderTier = .full + /// Composer keyboard focus, hoisted out of the composer bar so presenting the + /// lane sheet can park it and restore it on dismiss (mirrors + /// `HubComposerDrawer`'s destination-picker focus restore). + @State private var composerFocused: Bool = false + @State private var composerFocusedBeforeLaneSheet: Bool = false init( lanes: [LaneSummary], @@ -724,35 +774,41 @@ struct WorkNewChatScreen: View { var body: some View { VStack(spacing: 0) { - Spacer(minLength: 8) - + // Collapsible header. Everything here is expendable when the composer + // grows or the keyboard rises; the lane picker below is not. ScrollView { VStack(spacing: 14) { - brandMark - VStack(spacing: 6) { - Text("Start a new conversation") - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Ask ADE anything — refactor code, debug issues, or explore ideas.") - .font(.footnote) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) + if headerTier.showsBranding { + brandMark + VStack(spacing: 6) { + Text("Start a new conversation") + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Ask ADE anything — refactor code, debug issues, or explore ideas.") + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } } - laneSelector - sessionActionChips + if headerTier.showsActionChips { + sessionActionChips + } // Keep activity in the scrollable content instead of pinning it // above the composer. When the keyboard appears, the composer can // expand into this space without lifting the activity card with it. - WorkUsageActivityCarousel(refreshRevision: usageRefreshRevision) - .environmentObject(syncService) - .padding(.top, 2) - .fixedSize(horizontal: false, vertical: true) + if headerTier.showsUsageCarousel { + WorkUsageActivityCarousel(refreshRevision: usageRefreshRevision) + .environmentObject(syncService) + .padding(.top, 2) + .fixedSize(horizontal: false, vertical: true) + } } + .frame(maxWidth: .infinity) .padding(.horizontal, 20) - .padding(.vertical, 16) + .padding(.vertical, headerTier.showsBranding ? 16 : 8) } .scrollBounceBehavior(.basedOnSize) .scrollDismissesKeyboard(.interactively) @@ -760,6 +816,20 @@ struct WorkNewChatScreen: View { await MobileUsageQuotaStore.shared.load(using: syncService, refresh: true) usageRefreshRevision &+= 1 } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + applyHeaderHeight(height) + } + .animation(.smooth(duration: 0.2), value: headerTier) + .layoutPriority(0) + + // Pinned: the lane picker must stay reachable no matter how tall the + // composer grows or whether the keyboard is up. + laneSelector + .padding(.horizontal, 20) + .padding(.bottom, 10) + .layoutPriority(1) if let autoCreateStatus, busy { HStack(spacing: 8) { @@ -784,6 +854,7 @@ struct WorkNewChatScreen: View { } composerBar + .layoutPriority(1) } .adeScreenBackground() .adeNavigationGlass() @@ -857,12 +928,33 @@ struct WorkNewChatScreen: View { Spacer(minLength: 0) WorkLanePickerDropdown( lanes: lanes, - selectedLaneId: $selectedLaneId + selectedLaneId: $selectedLaneId, + onMenuPresentationChange: { presented in + // Presenting the sheet resigns the composer; bring the keyboard back + // when it closes so the flow stays continuous (same pattern as + // HubComposerDrawer's destination picker), but only if it had focus. + if presented { + composerFocusedBeforeLaneSheet = composerFocused + composerFocused = false + } else if composerFocusedBeforeLaneSheet { + composerFocusedBeforeLaneSheet = false + composerFocused = true + } + } ) Spacer(minLength: 0) } } + /// Steps the header tier from the measured scroll-area height, with a small + /// deadband so freeing height by hiding content cannot immediately re-show it + /// and start an oscillation. + private func applyHeaderHeight(_ height: CGFloat) { + guard height > 0 else { return } + let next = WorkNewChatHeaderTier.resolve(available: height, current: headerTier) + if next != headerTier { headerTier = next } + } + // Sits just above the composer, like the context chips in a chat. @ViewBuilder private var sessionActionChips: some View { @@ -956,6 +1048,7 @@ struct WorkNewChatScreen: View { runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, codexFastMode: $codexFastMode, + composerFocused: $composerFocused, onOpenModelPicker: { modelPickerPresented = true }, onSubmit: submit(openingMessage:attachments:) ) @@ -1399,6 +1492,8 @@ private struct WorkNewChatComposerBar: View { @Binding var runtimeMode: String @Binding var reasoningEffort: String @Binding var codexFastMode: Bool + /// Owned by the screen so the lane sheet can park and restore keyboard focus. + @Binding var composerFocused: Bool let onOpenModelPicker: () -> Void let onSubmit: @MainActor (String, [WorkChatInputAttachment]) async -> Bool @@ -1407,7 +1502,6 @@ private struct WorkNewChatComposerBar: View { @State private var attachments: [WorkChatInputAttachment] = [] @State private var attachmentPickerPresented = false @State private var composerTextHeight: CGFloat = 28 - @State private var composerFocused: Bool = false @StateObject private var dictationCoordinator = DictationInsertionCoordinator() @State private var isDictating = false /// Live viewport width of the controls scroll area, so the access control @@ -1466,10 +1560,7 @@ private struct WorkNewChatComposerBar: View { VStack(alignment: .leading, spacing: 12) { WorkPlainComposerTextView( text: $draft, - isFocused: Binding( - get: { composerFocused }, - set: { composerFocused = $0 } - ), + isFocused: $composerFocused, measuredHeight: $composerTextHeight, placeholder: placeholder, acceptsPastedImages: attachmentsAvailable, diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 20b5d6e8ff..5e5b191585 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -656,6 +656,26 @@ private func workPreviewOversizedQuestion() -> WorkPendingQuestionModel { } } +#Preview("Lane picker sheet") { + WorkLanePickerMenuPreviewHost() + .preferredColorScheme(.dark) +} + +private struct WorkLanePickerMenuPreviewHost: View { + @State private var searchQuery = "" + + var body: some View { + WorkLanePickerMenu( + lanes: [WorkPreviewData.lane], + allLanesEmpty: false, + selectedLaneId: WorkPreviewData.lane.id, + showsAutoCreateOption: true, + searchQuery: $searchQuery, + onSelect: { _ in } + ) + } +} + #Preview("Model picker") { WorkModelPickerSheet( currentModelId: WorkPreviewData.chatSummary.model, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index a7022489e2..673b4f1710 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -3258,6 +3258,29 @@ private func eventCard( ) case .systemNotice(let kind, let message, let detail, _, _): guard kind != "queue_recovery" else { return nil } + // ── Provider handoff ── + // Its own card kind so the timeline can draw the desktop divider + // (hairline · from logo · HANDOFF · arrow · to logo · hairline) instead + // of a text ribbon. A same-provider pair is not a handoff — the desktop + // no longer emits one, but old transcripts can still hold "Claude → + // Claude", so drop it here rather than draw a chip that says nothing. + if kind == workModelHandoffNoticeKind { + guard let providers = workModelHandoffProviders(fromDetail: detail), + providers.from != providers.to + else { return nil } + return WorkEventCardModel( + id: envelope.id, + kind: "modelHandoff", + // Doubles as the accessibility label for the logo-only divider. + title: message, + icon: "arrow.left.arrow.right", + tint: .secondary, + timestamp: envelope.timestamp, + body: nil, + bullets: [], + metadata: [providers.from, providers.to] + ) + } // ── Host sleep: ONE chip per sleep ── // The paused half creates the row; the resumed half carries the same // sleep id, so it lands on that row and replaces it rather than stacking diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index d724d858e4..eb272990f3 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -1034,13 +1034,15 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { ) ) case "model_handoff": + // Must produce the exact same shape as the live path in + // `WorkEventMapping` — a replayed transcript and a streamed event have + // to render the identical divider. + let handoffFrom = stringValue(eventDict["fromProvider"]) + let handoffTo = stringValue(eventDict["toProvider"]) event = .systemNotice( - kind: "info", - message: workModelHandoffNoticeMessage( - fromProvider: stringValue(eventDict["fromProvider"]), - toProvider: stringValue(eventDict["toProvider"]) - ), - detail: nil, + kind: workModelHandoffNoticeKind, + message: workModelHandoffNoticeMessage(fromProvider: handoffFrom, toProvider: handoffTo), + detail: workModelHandoffNoticeDetail(fromProvider: handoffFrom, toProvider: handoffTo), turnId: turnId, steerId: nil ) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 4fde71ed66..1c999ac613 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5078,12 +5078,53 @@ final class ADETests: XCTestCase { XCTAssertEqual(toModelId, "anthropic/claude-sonnet-5") XCTAssertEqual(turnId, "turn-handoff") - guard case .systemNotice(let kind, let message, _, let noticeTurnId, _) = makeWorkChatEvent(from: envelope.event) else { - return XCTFail("Expected model_handoff to map to a visible system notice.") + let liveEvent = makeWorkChatEvent(from: envelope.event) + guard case .systemNotice(let kind, let message, let detail, let noticeTurnId, _) = liveEvent else { + return XCTFail("Expected model_handoff to map to a handoff notice.") } - XCTAssertEqual(kind, "info") + XCTAssertEqual(kind, workModelHandoffNoticeKind) XCTAssertEqual(message, "Model handoff · Codex → Claude") XCTAssertEqual(noticeTurnId, "turn-handoff") + XCTAssertEqual(workModelHandoffProviders(fromDetail: detail)?.from, "codex") + XCTAssertEqual(workModelHandoffProviders(fromDetail: detail)?.to, "claude") + + // The divider is drawn from the card, so the card — not the notice text — + // is the contract: a dedicated kind plus the provider pair in `metadata`. + let liveCard = try XCTUnwrap(buildWorkEventCards(from: [ + WorkChatEnvelope( + sessionId: "session-handoff", + timestamp: "2026-09-01T00:00:00.000Z", + sequence: 8, + event: liveEvent + ) + ]).first) + XCTAssertEqual(liveCard.kind, "modelHandoff") + XCTAssertEqual(liveCard.metadata, ["codex", "claude"]) + XCTAssertEqual(liveCard.title, "Model handoff · Codex → Claude") + + // A replayed transcript has to land on the identical shape; the live and + // transcript paths are separate decoders. + let replayedCards = buildWorkEventCards(from: parseWorkChatTranscript(json)) + XCTAssertEqual(replayedCards, [liveCard]) + + // Same-provider transitions are not handoffs. The desktop stopped emitting + // them, but an old transcript can still carry "Claude → Claude". + let sameProvider = parseWorkChatTranscript(""" + { + "sessionId": "session-handoff", + "timestamp": "2026-09-01T00:00:00.000Z", + "sequence": 9, + "event": { + "type": "model_handoff", + "fromProvider": "claude", + "toProvider": "claude", + "fromModelId": "anthropic/claude-opus-5", + "toModelId": "anthropic/claude-fable-5-1", + "turnId": "turn-same" + } + } + """) + XCTAssertTrue(buildWorkEventCards(from: sameProvider).isEmpty) } func testAgentChatEventEnvelopeDecodesTokenUsageEvent() throws { From d3064e25d3eeda3e8744031c8cb7e1e3ebd01928 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:03:53 -0400 Subject: [PATCH 2/5] refactor(chat): apply quality review to handoff and mobile composer changes Restore composer focus after the lane sheet fully dismisses. Keep the usage carousel mounted while collapsed so it does not refetch. Move the handoff notice kind into AgentChatNoticeKind. Pass providers to the handoff divider directly. Consolidate the floating badge band heights. Hide same-provider handoff rows on desktop and the TUI as well. Co-Authored-By: Claude Fable 5.1 --- apps/ade-cli/src/tuiClient/format.ts | 3 + .../main/services/chat/agentChatService.ts | 15 ++- .../chat/AgentChatMessageList.test.tsx | 18 ++++ .../components/chat/AgentChatMessageList.tsx | 4 + apps/ios/ADE/Models/RemoteModels.swift | 3 + .../Work/WorkChatHeaderAndMessageViews.swift | 49 +++++----- .../Work/WorkChatSessionView+Timeline.swift | 9 +- .../ADE/Views/Work/WorkChatSessionView.swift | 26 +++--- .../ios/ADE/Views/Work/WorkEventMapping.swift | 2 +- .../Views/Work/WorkLanePickerDropdown.swift | 16 +++- apps/ios/ADE/Views/Work/WorkModels.swift | 6 -- .../ADE/Views/Work/WorkNewChatScreen.swift | 91 ++++++++++++------- .../ADE/Views/Work/WorkTimelineHelpers.swift | 2 +- .../ADE/Views/Work/WorkTranscriptParser.swift | 2 +- apps/ios/ADETests/ADETests.swift | 21 +++-- 15 files changed, 165 insertions(+), 102 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index d94b0eadab..c55f67b57d 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -793,6 +793,9 @@ export function renderChatLines(args: { continue; } if (event.type === "model_handoff") { + // Same-provider transitions are not handoffs (mirrors desktop/iOS): skip + // the line rather than print "Claude → Claude". + if (event.fromProvider === event.toProvider) continue; const from = providerDisplayLabel(event.fromProvider, "previous model"); const to = providerDisplayLabel(event.toProvider, "new model"); lines.push({ id, tone: "notice", body: `[model] ${from} → ${to}` }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e809a2aa6f..ec023db0f3 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -48748,19 +48748,18 @@ export function createAgentChatService(args: { ); } - const modelChanged = - previousProvider !== nextProvider - || managed.session.modelId !== descriptor.id - || managed.session.model !== nextModel; // A handoff chip marks a change of *agent*, not a change of model. Only // a different top-level ADE provider group qualifies: claude -> codex is // a handoff, Claude Opus -> Claude Fable is not. Aggregator providers // (opencode, cursor, droid) collapse to a single group, so switching the - // model they front (anthropic -> openai inside OpenCode) is also not a - // handoff. `modelChanged` stays broader on purpose — it still drives the - // runtime teardown and title re-adoption below, which any model switch - // needs. + // model they front is also not a handoff. `modelChanged` stays broader on + // purpose — it still drives the runtime teardown and title re-adoption + // below, which any model switch needs. const providerChanged = previousProvider !== nextProvider; + const modelChanged = + providerChanged + || managed.session.modelId !== descriptor.id + || managed.session.model !== nextModel; if (providerChanged) { modelHandoff = { fromProvider: previousProvider, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 6e72e15b3d..69d095d29d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1236,6 +1236,24 @@ describe("AgentChatMessageList transcript rendering", () => { expect(divider.querySelector(".items-center.h-6")).toBeTruthy(); }); + it("draws no handoff divider when the provider did not actually change", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "model_handoff", + fromProvider: "claude", + toProvider: "claude", + fromModelId: "anthropic/claude-opus-5", + toModelId: "anthropic/claude-sonnet-5", + }, + }, + ]); + + expect(screen.queryByTestId("model-handoff-event")).toBeNull(); + }); + it("draws exactly one fork-history divider between seeded history and the first live event", async () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index b08cb53cbd..71dc7a30cf 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2430,6 +2430,10 @@ function renderEvent( const event = envelope.event; if (event.type === "model_handoff") { + // A same-provider transition is not a handoff. The service no longer emits + // one, but an old transcript can still carry "Claude -> Claude"; drawing a + // divider with the same logo on both sides says nothing. + if (event.fromProvider === event.toProvider) return null; const fromLabel = providerDisplayLabel(event.fromProvider, "Previous model"); const toLabel = providerDisplayLabel(event.toProvider, "New model"); return ( diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4b91125a0d..bf74efc59f 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1859,6 +1859,9 @@ enum AgentChatNoticeKind: String, Codable, Equatable { case hostAsleep = "host_asleep" /// Resumed half of the same chip. case hostAwake = "host_awake" + /// Provider handoff divider. Synthesized locally from the `model_handoff` + /// event — see `workModelHandoffNoticeDetail`. + case modelHandoff = "model_handoff" // The host's noticeKind union (see apps/desktop/src/shared/types/chat.ts) grows // over time. `system_notice.noticeKind` is a required, non-optional decode, so an diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index f4a554595d..3ae70cbef5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -1259,36 +1259,35 @@ struct WorkTurnSeparatorView: View { /// Provider handoff divider: the transcript's "a different agent picked this /// thread up" marker. Mirrors desktop `AgentChatMessageList` — hairline, the /// outgoing provider's logo, a small uppercase "handoff" label, an arrow, the -/// incoming provider's logo, hairline. Model swaps *inside* one provider are -/// not handoffs and never reach here. +/// incoming provider's logo, hairline. Takes the two providers directly; the +/// same-provider filter and the `metadata` unpack live in `eventCard` and the +/// timeline call site, not here. struct WorkModelHandoffDivider: View { - let card: WorkEventCardModel + let fromProvider: String + let toProvider: String + let accessibilityLabel: String var body: some View { - let fromProvider = card.metadata.first - let toProvider = card.metadata.count > 1 ? card.metadata[1] : nil - if let fromProvider, let toProvider, fromProvider != toProvider { - HStack(spacing: 10) { - hairline - HStack(spacing: 8) { - providerMark(fromProvider) - Text("Handoff") - .font(.system(size: 10, weight: .semibold)) - .textCase(.uppercase) - .tracking(1.4) - .foregroundStyle(ADEColor.textMuted) - Image(systemName: "arrow.right") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - providerMark(toProvider) - } - hairline + HStack(spacing: 10) { + hairline + HStack(spacing: 8) { + providerMark(fromProvider) + Text("Handoff") + .font(.system(size: 10, weight: .semibold)) + .textCase(.uppercase) + .tracking(1.4) + .foregroundStyle(ADEColor.textMuted) + Image(systemName: "arrow.right") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + providerMark(toProvider) } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .accessibilityElement(children: .combine) - .accessibilityLabel(card.title) + hairline } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) } private func providerMark(_ provider: String) -> some View { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 40c1121b7e..ec7dc1ec46 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -408,8 +408,13 @@ extension WorkChatSessionView { enabled: isLive, onRecover: onRecoverCodexTurn ) - } else if card.kind == "modelHandoff" { - WorkModelHandoffDivider(card: card) + } else if card.kind == "modelHandoff", card.metadata.count == 2 { + WorkModelHandoffDivider( + fromProvider: card.metadata[0], + toProvider: card.metadata[1], + // The card title doubles as the divider's accessibility label. + accessibilityLabel: card.title + ) } else if card.kind == "turnDiagnostics" { WorkTurnDiagnosticsDisclosureView( card: card, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index fc5d5fc139..adca187f98 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1376,11 +1376,21 @@ struct WorkChatSessionView: View { return nil } + /// Total height the floating badge chip row occupies over the transcript: + /// the chip row itself plus the padding that holds it off the bottom edge. + private var floatingBadgeBandHeight: CGFloat { + workChatComposerChipRowHeight + workChatFloatingBadgeRowBottomPadding + } + + /// How far the scrim gradient fades above the badge band. + private var scrimFadeHeight: CGFloat { 20 } + var jumpToLatestPillBottomPadding: CGFloat { // The badge chip row floats over the same bottom-left corner of the // transcript. Stack the pill above it so both stay fully visible. guard showsComposerBadgeChips else { return 16 } - return workChatFloatingBadgeRowBottomPadding + workChatComposerChipRowHeight + 8 + let gapAboveBadges: CGFloat = 8 + return floatingBadgeBandHeight + gapAboveBadges } /// How many items the Chat Info sheet would show. Drives both the badge's @@ -1652,10 +1662,6 @@ struct WorkChatSessionView: View { WorkClaudeGoalPill(goal: claudeGoal) } - // Chat-info / PR badges no longer live here: they float over the - // transcript (see `composerBadgeChipRow`) so the thread scrolls behind - // them instead of losing 44pt of height to a fixed composer row. - if !pendingSteers.isEmpty { WorkQueuedSteerStrip( steers: pendingSteers, @@ -1840,9 +1846,7 @@ struct WorkChatSessionView: View { // The badge chips float over the transcript, so the tail has // to reserve their height or the last message hides behind // them. - + (showsComposerBadgeChips - ? workChatComposerChipRowHeight + workChatFloatingBadgeRowBottomPadding - : 0) + + (showsComposerBadgeChips ? floatingBadgeBandHeight : 0) ) .id("chat-end") .transaction { transaction in @@ -2021,11 +2025,7 @@ struct WorkChatSessionView: View { startPoint: .top, endPoint: .bottom ) - .frame( - height: workChatComposerChipRowHeight - + workChatFloatingBadgeRowBottomPadding - + 20 - ) + .frame(height: floatingBadgeBandHeight + scrimFadeHeight) .allowsHitTesting(false) } .transition(.opacity) diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 6457fa4a31..acd3eab027 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -688,7 +688,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { // `detail` so `buildWorkEventCards` can paint the desktop-style // logo → logo divider. The message stays the VoiceOver label. return .systemNotice( - kind: workModelHandoffNoticeKind, + kind: AgentChatNoticeKind.modelHandoff.rawValue, message: workModelHandoffNoticeMessage(fromProvider: fromProvider, toProvider: toProvider), detail: workModelHandoffNoticeDetail(fromProvider: fromProvider, toProvider: toProvider), turnId: turnId, diff --git a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift index 839a98e18b..9af0b834d9 100644 --- a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift +++ b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift @@ -71,7 +71,14 @@ struct WorkLanePickerDropdown: View { // the keyboard and a grown composer leave behind, which clipped the lane // list to a few rows. A sheet owns its own space and resizes for the // keyboard instead. - .sheet(isPresented: $menuPresented) { + .sheet(isPresented: $menuPresented, onDismiss: { + // The closed edge fires here, not from `onChange`: `menuPresented` + // flips when the dismissal *starts*, so restoring composer focus from + // there races the sheet still animating away and the keyboard loses. + // `onDismiss` runs once the sheet is actually gone. + searchQuery = "" + onMenuPresentationChange?(false) + }) { WorkLanePickerMenu( lanes: filteredLanes, allLanesEmpty: lanes.isEmpty, @@ -90,8 +97,9 @@ struct WorkLanePickerDropdown: View { .presentationDragIndicator(.visible) } .onChange(of: menuPresented) { _, isOpen in - if !isOpen { searchQuery = "" } - onMenuPresentationChange?(isOpen) + // Only the open edge; the closed edge is reported from `onDismiss`. + guard isOpen else { return } + onMenuPresentationChange?(true) } if let onRefresh { @@ -346,7 +354,7 @@ struct WorkLanePickerMenu: View { /// Desktop `ade-orchestrator-rainbow-text` gradient label for auto-create lane. private struct WorkOrchestratorRainbowText: View { let text: String - var size: CGFloat = 11 + let size: CGFloat private static let colors: [Color] = [ Color(red: 1.0, green: 0.37, blue: 0.37), diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 72b89c2040..121ddaa159 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -261,12 +261,6 @@ func workModelHandoffNoticeMessage(fromProvider: String, toProvider: String) -> return "Model handoff · \(from) → \(to)" } -/// Notice kind reserved for a provider handoff. A handoff rides the notice -/// channel so no exhaustive `WorkChatEvent` switch has to grow a case, but it -/// is lifted out of the generic notice card in `buildWorkEventCards` and drawn -/// as the logo → logo divider instead of a text ribbon. -let workModelHandoffNoticeKind = "model_handoff" - /// Packs the two provider ids into the notice `detail` field. Provider ids are /// slugs (`claude`, `codex`, `opencode`, …) so the pipe is unambiguous, and the /// notice payload stays a plain `String?` — no model change needed. diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 61f1dcf32a..a231e3b644 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -777,34 +777,46 @@ struct WorkNewChatScreen: View { // Collapsible header. Everything here is expendable when the composer // grows or the keyboard rises; the lane picker below is not. ScrollView { - VStack(spacing: 14) { - if headerTier.showsBranding { - brandMark - VStack(spacing: 6) { - Text("Start a new conversation") - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Ask ADE anything — refactor code, debug issues, or explore ideas.") - .font(.footnote) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) + // Outer stack is unspaced so the collapsed carousel below contributes + // neither height nor inter-item spacing. + VStack(spacing: 0) { + VStack(spacing: 14) { + if headerTier.showsBranding { + brandMark + VStack(spacing: 6) { + Text("Start a new conversation") + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Ask ADE anything — refactor code, debug issues, or explore ideas.") + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } } - } - if headerTier.showsActionChips { - sessionActionChips + if headerTier.showsActionChips { + sessionActionChips + } } // Keep activity in the scrollable content instead of pinning it // above the composer. When the keyboard appears, the composer can // expand into this space without lifting the activity card with it. - if headerTier.showsUsageCarousel { - WorkUsageActivityCarousel(refreshRevision: usageRefreshRevision) - .environmentObject(syncService) - .padding(.top, 2) - .fixedSize(horizontal: false, vertical: true) - } + // + // The carousel stays mounted at every tier and collapses to nothing + // when the tier hides it: it owns `@State` stats behind a + // `.task(id:)`, so removing it from the tree would refetch and flash + // an empty card every time the header tier stepped back up. + WorkUsageActivityCarousel(refreshRevision: usageRefreshRevision) + .environmentObject(syncService) + .padding(.top, usageCarouselTopPadding) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: headerTier.showsUsageCarousel ? nil : 0) + .clipped() + .opacity(headerTier.showsUsageCarousel ? 1 : 0) + .allowsHitTesting(headerTier.showsUsageCarousel) + .accessibilityHidden(!headerTier.showsUsageCarousel) } .frame(maxWidth: .infinity) .padding(.horizontal, 20) @@ -929,23 +941,36 @@ struct WorkNewChatScreen: View { WorkLanePickerDropdown( lanes: lanes, selectedLaneId: $selectedLaneId, - onMenuPresentationChange: { presented in - // Presenting the sheet resigns the composer; bring the keyboard back - // when it closes so the flow stays continuous (same pattern as - // HubComposerDrawer's destination picker), but only if it had focus. - if presented { - composerFocusedBeforeLaneSheet = composerFocused - composerFocused = false - } else if composerFocusedBeforeLaneSheet { - composerFocusedBeforeLaneSheet = false - composerFocused = true - } - } + onMenuPresentationChange: handleLaneSheetPresentation ) Spacer(minLength: 0) } } + /// Parks composer focus while the lane sheet is up and restores it after the + /// sheet has finished dismissing, so the flow stays continuous (same pattern + /// as HubComposerDrawer's destination picker) — but only if it had focus. + private func handleLaneSheetPresentation(_ presented: Bool) { + if presented { + composerFocusedBeforeLaneSheet = composerFocused + composerFocused = false + } else if composerFocusedBeforeLaneSheet { + composerFocusedBeforeLaneSheet = false + composerFocused = true + } + } + + /// Gap above the usage carousel. Matches what the old `VStack(spacing: 14)` + /// plus its 2pt top padding produced: 16pt below whatever precedes it, 2pt + /// when it is the only header content, nothing at all when it is collapsed. + private var usageCarouselTopPadding: CGFloat { + guard headerTier.showsUsageCarousel else { return 0 } + // The chips row only renders for a concrete lane, so key off what is + // actually drawn rather than off the tier flag alone. + let chipsRendered = headerTier.showsActionChips && selectedConcreteLane != nil + return (headerTier.showsBranding || chipsRendered) ? 16 : 2 + } + /// Steps the header tier from the measured scroll-area height, with a small /// deadband so freeing height by hiding content cannot immediately re-show it /// and start an oscillation. diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 673b4f1710..3fe908a14f 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -3264,7 +3264,7 @@ private func eventCard( // of a text ribbon. A same-provider pair is not a handoff — the desktop // no longer emits one, but old transcripts can still hold "Claude → // Claude", so drop it here rather than draw a chip that says nothing. - if kind == workModelHandoffNoticeKind { + if kind == AgentChatNoticeKind.modelHandoff.rawValue { guard let providers = workModelHandoffProviders(fromDetail: detail), providers.from != providers.to else { return nil } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index eb272990f3..bb5675710c 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -1040,7 +1040,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { let handoffFrom = stringValue(eventDict["fromProvider"]) let handoffTo = stringValue(eventDict["toProvider"]) event = .systemNotice( - kind: workModelHandoffNoticeKind, + kind: AgentChatNoticeKind.modelHandoff.rawValue, message: workModelHandoffNoticeMessage(fromProvider: handoffFrom, toProvider: handoffTo), detail: workModelHandoffNoticeDetail(fromProvider: handoffFrom, toProvider: handoffTo), turnId: turnId, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 1c999ac613..a362adc5fb 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5082,7 +5082,7 @@ final class ADETests: XCTestCase { guard case .systemNotice(let kind, let message, let detail, let noticeTurnId, _) = liveEvent else { return XCTFail("Expected model_handoff to map to a handoff notice.") } - XCTAssertEqual(kind, workModelHandoffNoticeKind) + XCTAssertEqual(kind, AgentChatNoticeKind.modelHandoff.rawValue) XCTAssertEqual(message, "Model handoff · Codex → Claude") XCTAssertEqual(noticeTurnId, "turn-handoff") XCTAssertEqual(workModelHandoffProviders(fromDetail: detail)?.from, "codex") @@ -5090,11 +5090,14 @@ final class ADETests: XCTestCase { // The divider is drawn from the card, so the card — not the notice text — // is the contract: a dedicated kind plus the provider pair in `metadata`. + // Build the live envelope from the parsed transcript one so the event is + // the only thing that differs between the two paths. + let parsed = try XCTUnwrap(parseWorkChatTranscript(json).first) let liveCard = try XCTUnwrap(buildWorkEventCards(from: [ WorkChatEnvelope( - sessionId: "session-handoff", - timestamp: "2026-09-01T00:00:00.000Z", - sequence: 8, + sessionId: parsed.sessionId, + timestamp: parsed.timestamp, + sequence: parsed.sequence, event: liveEvent ) ]).first) @@ -5104,11 +5107,12 @@ final class ADETests: XCTestCase { // A replayed transcript has to land on the identical shape; the live and // transcript paths are separate decoders. - let replayedCards = buildWorkEventCards(from: parseWorkChatTranscript(json)) - XCTAssertEqual(replayedCards, [liveCard]) + XCTAssertEqual(buildWorkEventCards(from: [parsed]), [liveCard]) + } - // Same-provider transitions are not handoffs. The desktop stopped emitting - // them, but an old transcript can still carry "Claude → Claude". + /// Same-provider transitions are not handoffs. The desktop stopped emitting + /// them, but an old transcript can still carry "Claude → Claude". + func testModelHandoffSameProviderProducesNoCard() throws { let sameProvider = parseWorkChatTranscript(""" { "sessionId": "session-handoff", @@ -5124,6 +5128,7 @@ final class ADETests: XCTestCase { } } """) + XCTAssertFalse(sameProvider.isEmpty, "The transcript row itself still parses.") XCTAssertTrue(buildWorkEventCards(from: sameProvider).isEmpty) } From 8b49f38917596ad6256586311bbd7f26fa008f38 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:11:30 -0400 Subject: [PATCH 3/5] test(chat): pin handoff guards and header tiers; sync docs Add a TUI test for the same-provider handoff skip and iOS tests for the new-chat header tier thresholds and hysteresis. Raise lane sheet rows to a 44pt tap target. Update the chat, ADE Code, and iOS companion docs. Co-Authored-By: Claude Fable 5.1 --- .../src/tuiClient/__tests__/format.test.ts | 25 ++++++ .../Views/Work/WorkLanePickerDropdown.swift | 2 +- apps/ios/ADETests/ADETests.swift | 76 +++++++++++++++++++ docs/features/ade-code/README.md | 2 +- docs/features/chat/composer-and-ui.md | 20 +++-- docs/features/chat/transcript-and-turns.md | 2 +- .../sync-and-multi-device/ios-companion.md | 41 ++++++++-- 7 files changed, 155 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 37ad2d91aa..1e535efdc8 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -127,6 +127,31 @@ describe("renderChatLines", () => { }); }); + it("skips a handoff line when the provider did not actually change", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [{ + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "model_handoff", + fromProvider: "claude", + toProvider: "claude", + fromModelId: "anthropic/claude-opus-5", + toModelId: "anthropic/claude-sonnet-5", + }, + }], + }); + + // Swapping Opus for Sonnet is the same agent, so "[model] Claude → Claude" + // is noise. Desktop and iOS drop the row too; the TUI must agree. + expect(lines).toHaveLength(0); + expect(lines.some((line) => line.body.includes("[model]"))).toBe(false); + expect(lines.some((line) => line.tone === "notice")).toBe(false); + }); + it("LRU-caches assistant markdown parses by message text", () => { __clearAssistantMarkdownCacheForTests(); const text = "Paragraph text\n\n```ts\nconst value = 1;\n```"; diff --git a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift index 9af0b834d9..75e3a1be83 100644 --- a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift +++ b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift @@ -338,7 +338,7 @@ struct WorkLanePickerMenu: View { } } .padding(.horizontal, 10) - .padding(.vertical, 10) + .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a362adc5fb..3d7ff78f86 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5132,6 +5132,82 @@ final class ADETests: XCTestCase { XCTAssertTrue(buildWorkEventCards(from: sameProvider).isEmpty) } + /// The new-chat header sheds branding, then the usage carousel, then the + /// action chips as the scroll area shrinks. Shrinking is unconditional: only + /// growing back pays the hysteresis, so a keyboard opening must collapse the + /// header at the exact published thresholds (full 300, compact 190, + /// minimal 96) with nothing held back. + func testNewChatHeaderTierCollapsesAtEachThresholdWhenHeightShrinks() { + let cases: [(available: CGFloat, current: WorkNewChatHeaderTier, expected: WorkNewChatHeaderTier)] = [ + (1000, .full, .full), + (300, .full, .full), + (299.9, .full, .compact), + (190, .compact, .compact), + (189.9, .compact, .minimal), + (96, .minimal, .minimal), + (95.9, .minimal, .hidden), + (0, .full, .hidden), + // A collapse can skip tiers: the keyboard takes the whole header at once. + (95, .full, .hidden), + ] + + for testCase in cases { + XCTAssertEqual( + WorkNewChatHeaderTier.resolve(available: testCase.available, current: testCase.current), + testCase.expected, + "available=\(testCase.available) current=\(testCase.current)" + ) + } + } + + /// Stepping *up* needs 24pt more than the tier's own threshold. Without it, + /// revealing content that re-consumes the height would immediately re-collapse + /// the header and the two tiers would oscillate on every layout pass. + func testNewChatHeaderTierNeedsHysteresisToExpandAgain() { + let cases: [(available: CGFloat, current: WorkNewChatHeaderTier, expected: WorkNewChatHeaderTier)] = [ + // minimal: 96 + 24 + (119, .hidden, .hidden), + (120, .hidden, .minimal), + // compact: 190 + 24 + (213, .minimal, .minimal), + (214, .minimal, .compact), + // full: 300 + 24 + (323, .compact, .compact), + (324, .compact, .full), + ] + + for testCase in cases { + XCTAssertEqual( + WorkNewChatHeaderTier.resolve(available: testCase.available, current: testCase.current), + testCase.expected, + "available=\(testCase.available) current=\(testCase.current)" + ) + } + + // The height that just expanded the header must be stable at the new tier: + // re-resolving from the tier it produced cannot bounce back down. + XCTAssertEqual(WorkNewChatHeaderTier.resolve(available: 120, current: .minimal), .minimal) + XCTAssertEqual(WorkNewChatHeaderTier.resolve(available: 214, current: .compact), .compact) + XCTAssertEqual(WorkNewChatHeaderTier.resolve(available: 324, current: .full), .full) + } + + /// The tiers gate real content, so the branding/carousel/chips flags must stay + /// aligned with the ordering the resolver relies on. + func testNewChatHeaderTierContentFlagsFollowTheOrdering() { + XCTAssertEqual( + [WorkNewChatHeaderTier.hidden, .minimal, .compact, .full].map(\.showsActionChips), + [false, true, true, true] + ) + XCTAssertEqual( + [WorkNewChatHeaderTier.hidden, .minimal, .compact, .full].map(\.showsUsageCarousel), + [false, false, true, true] + ) + XCTAssertEqual( + [WorkNewChatHeaderTier.hidden, .minimal, .compact, .full].map(\.showsBranding), + [false, false, false, true] + ) + } + func testAgentChatEventEnvelopeDecodesTokenUsageEvent() throws { let json = """ { diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 91f2204af1..30763650e6 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -45,7 +45,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/modelState.ts` | Pure model/setup state for draft chats and `/model`: GPT-5.6 Sol default plus Sol/Terra/Luna ordering, Chat vs CLI interface mode, Cursor chat-vs-CLI availability reconciliation, Codex preset/approval/sandbox mapping, provider-specific permission summaries, host-aware reasoning defaults/visible tiers, Fast Mode support, and the `SetupPaneRow` list rendered in setup panes. GPT-5.6 labels `low` as Light and `xhigh` as Extra High, exposes Max on all three models, and adds Ultra after Max on Sol/Terra. | | `apps/ade-cli/src/tuiClient/modelPickerController.ts` | Small adapter between right-pane model-picker state and `modelPickerLayout.ts`: supplies active model/reasoning/interface, favorites/recents, AI status, footer focus, lane label, and provider refresh routing. | | `apps/ade-cli/src/tuiClient/rightPaneFormatters.ts` | Pure formatters for right-pane result panes (PR summary / review / checks / comments, Linear status, Cursor Cloud fleet rows, system details). Keeps `app.tsx` free of ad-hoc rendering helpers. The PR checks formatter does not tally rows itself — it runs them through the shared `rollupPrChecks` so the TUI applies the same CI-producer rule as desktop and iOS, and a payload `checksStatus` of `not_run` still wins, because only the host knows about required contexts because only the host knows about required contexts. A `not_run` rollup renders as `CI: not run — ` instead of a passing count. See [pull-requests](../pull-requests/README.md#checks-rollup-what-counts-as-a-pass). The PR summary's lane line names the local worktree you can work in and nothing more: a PR without one is fully operable, so a detached row (its lane deleted or moved to another branch, leaving a dangling `laneId` that points at a worktree that is not there) prints no lane line rather than a `was ` history string. Same rule as the `ade prs list` lane column. See [A lane link is not a permission](../pull-requests/README.md#a-lane-link-is-not-a-permission). | -| `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. `webSearchResultPreviewLines` / `webSearchResultDomain` turn Codex structured web-search `results` into compact `title — domain` lines with a `+N more` tail, shared by `renderChatLines` (subagent pane) and `ChatView` (work log). | +| `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. `webSearchResultPreviewLines` / `webSearchResultDomain` turn Codex structured web-search `results` into compact `title — domain` lines with a `+N more` tail, shared by `renderChatLines` (subagent pane) and `ChatView` (work log). `renderChatLines` also owns the `[model] Codex → Claude` handoff line, and skips it when a `model_handoff` event names the same provider on both sides (mirroring desktop and iOS) so an older transcript cannot print `[model] Claude → Claude`. | | `apps/ade-cli/src/tuiClient/displayWidth.ts` | Grapheme-aware terminal-cell helpers using `string-width`: code-unit ↔ display-cell mapping, display-cell slicing, truncation, wrapping, and selection splitting for Unicode-safe chat/model/right-pane rendering. | | `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, connector-aware tool calls, files changed, web/image/plan/compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. MCP app/server identity replaces generic tool labels and image lifecycle updates collapse by item id. | | `apps/ade-cli/src/tuiClient/bracketedPaste.ts` | Bracketed-paste parser/formatter for terminal-control mode and multi-line forwarded input. Normalizes pasted newlines and wraps multi-line user input in bracketed-paste markers before writing it into provider CLI PTYs. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index ad40287221..62176afa0f 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -267,11 +267,21 @@ that could not work without it. provider and snap the picker back to a default. The pending pick is scoped to that chat: switching to another locked Work chat hydrates the incoming session instead of carrying the pick across. Send applies the existing - handoff together with those pending native controls, records a `model_handoff` - divider with the previous and current provider marks (20px marks, one - baseline), and keeps the current provider mark stacked above prior handoff - marks on the Work session card. The TUI prints `[model] Codex → Claude`. - iOS renders the same event as a system notice (`Model handoff · Claude → Codex`). + handoff together with those pending native controls. A `model_handoff` + divider is recorded only when the *top-level provider group* actually + changes — `claude` → `codex` is a handoff; Claude Opus → Claude Fable is + not, and neither is swapping the vendor model fronted by an aggregator + provider (OpenCode, Cursor, and Droid each collapse to a single group). + A same-provider switch is still a full model change: it tears down and + rebinds the provider runtime and re-adopts the title, it just emits no + handoff, because a divider with the same logo on both sides says nothing. + When one is emitted, desktop draws the divider with the previous and + current provider marks (20px marks, one baseline) and keeps the current + provider mark stacked above prior handoff marks on the Work session card. + The TUI prints `[model] Codex → Claude`. iOS draws its own + logo → `HANDOFF` → logo divider. All three renderers additionally drop a + same-provider pair at render time, so an older transcript that already + carries `Claude → Claude` stops showing it. - **Text input** with auto-grow up to `composerMaxHeightPx`. Grid tiles pass a fixed 144 px ceiling (computed statically from `layoutVariant`) diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 3ba2024e19..42ee3c25b5 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -149,7 +149,7 @@ Two helpers summarise a parsed stream: | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). | | `step_boundary` | Workflow step boundary marker. | | `system_notice` | Non-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, the `status:"subagent_spawned"` chip (emitted into the parent when a child chat session is created with a parent lineage; `detail.spawnedSession` carries the child sessionId/laneId/title and the chip deep-links via `ade:work:select-session`; the TUI shows the message line; iOS renders it through its existing system_notice mapping), the quiet `status:"model_switched"` divider after a Claude Pre/PostModelSwitch, and `status:"classifier_context"` audit lines when ADE relays user-authored classifierContext. | -| `model_handoff` | A completed provider/model transition. Desktop draws a divider with previous and current provider marks. The TUI prints `[model] Codex → Claude`. iOS maps it to a system notice (`Model handoff · Codex → Claude`). Emitted on Send when the composer model differs from the committed session. | +| `model_handoff` | A completed **provider** transition. Emitted on Send only when the committed session's top-level provider group changes (`claude` → `codex`); a model switch inside one provider — including swapping the vendor model fronted by an aggregator provider, since OpenCode, Cursor, and Droid each collapse to a single group — is a model change but not a handoff and emits nothing. Desktop draws a divider with previous and current provider marks. The TUI prints `[model] Codex → Claude`. iOS renders a hairline · from-logo · `HANDOFF` · arrow · to-logo divider: `WorkEventMapping` routes the event onto the notice channel under `AgentChatNoticeKind.modelHandoff` with the provider pair packed into the notice `detail` (`from|to`), and the `Model handoff · Codex → Claude` sentence survives as the VoiceOver label. All three renderers additionally skip a same-provider pair, so legacy transcripts holding `Claude → Claude` no longer draw one. | | `conversation_reset` | Marks a fresh Claude conversation inside the same ADE session. ADE adopts `newConversationId` as the next SDK resume pointer, clears conversation-scoped auto-title/continuity caches while preserving a manual title, and renderers show a `New conversation` divider. | | `interrupt_receipt` | Records SDK UUIDs that remain queued or were cancelled after an interrupt, plus the selected `stopMode`. Clients show the full remaining count; ADE-attributed messages include their `steerId` and offer cancellation through the SDK control channel. The long-lived query remains attached while messages are still queued so the receipt stays actionable. | | `queue_recovery` | Bounded recovery lifecycle for Claude queue-clearing Stop (`stop_and_clear` / `stop_and_clear_and_background`): `available` renders one Undo card for the actually cancelled ADE-attributed messages, `restored` rehydrates the original steer payloads/ids, and `expired` closes the eight-second window. Terminal recovery events suppress the earlier available card during replay. | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 1c1101cdc2..2bde80327c 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -393,14 +393,20 @@ apps/ios/ │ │ │ # WorkViewStateStore), │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI │ │ │ # launcher + per-project interface -│ │ │ # preference shared with Hub), +│ │ │ # preference shared with Hub; pinned +│ │ │ # lane picker under a tiered +│ │ │ # progressive-disclosure header, +│ │ │ # WorkNewChatHeaderTier), │ │ │ # WorkUsageActivityCarousel (host quota │ │ │ # limits + cross-client activity charts), │ │ │ # WorkImportSessionScreen + │ │ │ # WorkExternalSessionAffordances │ │ │ # (provider session browse/details, │ │ │ # lane picker, Continue/Copy policy), -│ │ │ # WorkLanePickerDropdown, +│ │ │ # WorkLanePickerDropdown (sheet-presented +│ │ │ # searchable lane list; reports its +│ │ │ # presentation state so a caller can +│ │ │ # park and restore composer focus), │ │ │ # WorkChatRichCardViews (de-glassed, │ │ │ # centered compact tool-call / │ │ │ # file-change summary rows that expand @@ -2510,7 +2516,7 @@ Known limits, all deliberate: |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/manage, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) is organized into collapsible sections (`LaneDetailSectionChrome`): each section auto-opens when it has content and auto-collapses when empty (`LaneSectionDisclosure`), and stays where the user last put it once they toggle it manually. Header chips and the git action buttons flow through `LaneChipFlowLayout`, a wrapping flow layout that wraps onto new lines instead of horizontally scrolling. Lane rows in the list carry a cheap render-relevant signature (mirroring the Hub row-signature pattern) so `.equatable()` re-renders only rows whose visible state changed. It embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. The sheet keeps the lane name in the nav bar with a pencil rename on the right (hidden for the primary lane and hosts that omit `lanes.rename`), drops the oversized body title, and shows branch and path as icon rows. Every lane is managed the same way regardless of where its worktree lives, so there is no adopt or "move into `.ade/worktrees`" action. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `FilesSearchPanel` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | -| **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. The session list itself is described in [Work session list rows](#work-session-list-rows). Each row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside its title. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | +| **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The lane picker is **pinned** directly above the composer, so it stays reachable no matter how tall the composer grows or whether the keyboard is up; everything above it is a collapsible header that steps through `WorkNewChatHeaderTier` (`full` word-mark + tagline + chips + usage carousel -> `compact` chips + carousel -> `minimal` chips only -> `hidden`). The tier is resolved from the measured height actually left for the scroll area — never from keyboard notifications — so a grown composer collapses the header exactly like the keyboard does, and a small hysteresis on stepping *up* keeps it from oscillating once hidden content frees the height that would re-show it. The usage carousel stays mounted at every tier and collapses to zero height rather than leaving the tree: it owns fetched stats behind a `.task(id:)`, so unmounting it would refetch and flash an empty card each time the tier stepped back up. The picker presents its list as a **sheet** (medium/large detents, drag indicator, 16pt-class text, keyboard-dismissing scroll) rather than a popover, because UIKit squeezed a popover into whatever space the keyboard and a grown composer left and clipped the lane list to a few rows. The screen owns the composer's focus binding so it can park focus while that sheet is up and restore it from the sheet's `onDismiss` — restoring on the `isPresented` change instead would race the dismissal animation and lose the keyboard. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. The session list itself is described in [Work session list rows](#work-session-list-rows). Each row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside its title. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: GitHub stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, Integration/Rebase workflow cards rendered from `PrWorkflowCard`, and per-PR action capabilities. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | | **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`) and publishes `ctoAttention`. It rides the change pulse that rebuilds the session roster, but is invoked *before* `refreshActiveSessionsAndSnapshot`'s roster-signature early return — the CTO is excluded from that roster, so a CTO-only change leaves the signature unchanged and a probe below the guard could never fire. `saveRemoteCommandDescriptors` also calls it with `force: true`, so the first probe after a (re)connect happens as soon as the host advertises the command. Transport failures and the host's explicit `unknown` status both keep the last known value; an older brain that does not advertise the action clears it. The decoded status is optional so a new phone still infers idle/waiting correctly from the legacy `awaitingInput` field. | | **Settings** | `gearshape` | `/settings` (sync subset) | Connections — account sign-in (primary, PIN-less directory + Relay adoption), account-wide machine rename/clear, scan the QR (`SettingsPairingScannerSheet`) + PIN, or Nearby + PIN — plus advanced SSH bootstrap, appearance, diagnostics, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. The About row formats the marketing and build versions together as `v ()`. Settings also hosts the full **Usage** page (`SettingsUsagePage`), the phone's counterpart of desktop Settings > Usage. | @@ -2995,7 +3001,7 @@ the stats and shows update guidance. | Hub personal chats | Implemented; runtime-scoped list/create/read/send/interactive actions, owner-only scheduled-work creation capability, controller Cancel/Pause actions, per-host offline summary cache, explicit personal transcript subscriptions, native new-chat/model flow, Chat Info Cancel/Pause controls, and project/lane actions suppressed | | Lanes tab | Implemented to live machine parity (with `devicesOpen`, stack canvas, stack-position/base-branch editing in Manage Lane, and template environment progress) | | Files tab | Implemented with freely-editable workspaces (mobile read-only file gate removed) and a unified full-screen name + content search page (`FilesSearchScreen`) | -| Work tab | Implemented; live chat-event push from runtime, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`) with camera-roll and pasted-image prompts, external provider-session browse/import (`work.listExternalSessions` / `work.importExternalSession`), message-to-continue on ended agent CLI rows, fixed cross-client activity carousel above the new-chat composer | +| Work tab | Implemented; live chat-event push from runtime, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`) with camera-roll and pasted-image prompts, external provider-session browse/import (`work.listExternalSessions` / `work.importExternalSession`), message-to-continue on ended agent CLI rows, cross-client activity carousel in the new-chat screen's collapsible header (kept mounted, collapsed rather than unmounted, when the header tier hides it) | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | | Settings tab (pairing / appearance / diagnostics) | Implemented | | Automations / Graph / History tabs | Planned | @@ -3388,12 +3394,37 @@ the stats and shows update guidance. `recalculating`; a failed authoritative read shows `unknown` rather than a stale percentage. Exact post-compaction tokens or a later measured snapshot refill the meter immediately. +- **Chat-info and PR badges float over the thread, they do not cost it + height.** The Chat Info count chip and the lane's PR chip used to be a fixed + 44pt row inside the composer inset, which took that height off the + transcript on every chat that had one. They now render as a bottom-leading + overlay on the transcript behind a short scrim, so the thread scrolls + underneath them — the same treatment as the "jump to latest" pill, which + stacks above the chip band rather than colliding with it. Two things have to + move together with that: the transcript's tail spacer reserves the band's + height so the last message can still scroll clear of the chips, and the row + is a `ViewThatFits` plain `HStack` (falling back to a horizontal scroller + only if a future chip actually overflows) — a full-width horizontal + `ScrollView` in that band would swallow vertical drags meant for the thread. + +- **A provider handoff is a divider, not a text ribbon.** `model_handoff` + rides the notice channel under its own `AgentChatNoticeKind.modelHandoff` + with the two provider ids packed into the notice `detail` as `from|to`, so + `buildWorkEventCards` can paint the desktop-shaped + hairline · logo · `HANDOFF` · arrow · logo divider instead of a sentence; + the `Model handoff · Codex → Claude` string survives as the accessibility + label. A malformed `detail` drops the row rather than drawing it half-empty, + and a same-provider pair is dropped too — the host no longer emits one, but + old transcripts still hold `Claude → Claude`, and a divider with the same + logo on both sides says nothing. See + [chat transcript-and-turns.md](../chat/transcript-and-turns.md). + - **Subagent lifecycle is rendered as chat structure, not event spam.** `RemoteModels.swift` accepts both legacy `subagent_started` / `subagent_progress` / `subagent_result` events and the canonical dotted `subagent.started` / `subagent.progress` / `subagent.completed` forms. `WorkChatSessionView` keeps the roster out of the transcript head; the - roster lives in Chat Info, while the compact composer badge remains the + roster lives in Chat Info, while the compact badge chip remains the at-a-glance entry point. `WorkTimelineHelpers` (`buildWorkSubagentTimelineRows`) collapses the raw lifecycle ticks into the same durable structure the desktop transcript uses — one spawn row From 4ea607b33809f2fef4ccbdeb2664a868940db3ca Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:19:55 -0400 Subject: [PATCH 4/5] ship: apply initial quality revalidation Drop two vacuous assertions from the TUI handoff test. Co-Authored-By: Claude Fable 5.1 --- apps/ade-cli/src/tuiClient/__tests__/format.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 1e535efdc8..5932c6cacc 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -148,8 +148,6 @@ describe("renderChatLines", () => { // Swapping Opus for Sonnet is the same agent, so "[model] Claude → Claude" // is noise. Desktop and iOS drop the row too; the TUI must agree. expect(lines).toHaveLength(0); - expect(lines.some((line) => line.body.includes("[model]"))).toBe(false); - expect(lines.some((line) => line.tone === "notice")).toBe(false); }); it("LRU-caches assistant markdown parses by message text", () => { From 04fae1d36167a6a0fff431ab030a87c2b2c37e4b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:36:21 -0400 Subject: [PATCH 5/5] fix(chat): drop same-provider handoff rows before layout; strict detail parse Filter same-provider handoff events out of the desktop transcript rows next to the automatic context-usage filter, so no empty row or gap is mounted. On iOS, reject a handoff detail with more than one pipe. Co-Authored-By: Claude Fable 5.1 --- .../chat/AgentChatMessageList.test.tsx | 6 +++- .../components/chat/AgentChatMessageList.tsx | 33 ++++++++++++++----- apps/ios/ADE/Views/Work/WorkModels.swift | 6 ++-- apps/ios/ADETests/ADETests.swift | 4 +++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 69d095d29d..c80910d6c1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1237,7 +1237,7 @@ describe("AgentChatMessageList transcript rendering", () => { }); it("draws no handoff divider when the provider did not actually change", () => { - renderMessageList([ + const { container } = renderMessageList([ { sessionId: "session-1", timestamp: "2026-03-17T10:00:00.000Z", @@ -1252,6 +1252,10 @@ describe("AgentChatMessageList transcript rendering", () => { ]); expect(screen.queryByTestId("model-handoff-event")).toBeNull(); + // The envelope is filtered out upstream, so no row wrapper is mounted at + // all — an empty row would still consume a `--chat-row-gap`. + const rowList = container.querySelector('[class*="--chat-row-gap"]'); + expect(rowList?.children.length ?? 0).toBe(0); }); it("draws exactly one fork-history divider between seeded history and the first live event", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 71dc7a30cf..1561ae1e70 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2277,6 +2277,20 @@ function isAutomaticContextUsageEvent(event: { type: string; origin?: string }): return event.type === "context_usage" && event.origin !== undefined && event.origin !== "command"; } +/** + * A same-provider transition is not a handoff. The service no longer emits one, + * but an old transcript can still carry "Claude -> Claude"; drawing a divider + * with the same logo on both sides says nothing. Filtered out alongside the + * automatic context-usage snapshots so no empty row (and its gap) is mounted. + */ +function isSameProviderModelHandoffEvent(event: { + type: string; + fromProvider?: string; + toProvider?: string; +}): boolean { + return event.type === "model_handoff" && event.fromProvider === event.toProvider; +} + function QueueRecoveryCard({ recoveryId, messageCount, @@ -2430,10 +2444,8 @@ function renderEvent( const event = envelope.event; if (event.type === "model_handoff") { - // A same-provider transition is not a handoff. The service no longer emits - // one, but an old transcript can still carry "Claude -> Claude"; drawing a - // divider with the same logo on both sides says nothing. - if (event.fromProvider === event.toProvider) return null; + // Same-provider handoffs never reach here: they are dropped upstream by + // `isSameProviderModelHandoffEvent` so they do not mount an empty row. const fromLabel = providerDisplayLabel(event.fromProvider, "Previous model"); const toLabel = providerDisplayLabel(event.toProvider, "New model"); return ( @@ -5646,10 +5658,15 @@ function AgentChatMessageListMain({ return byRowKey; }, [rows]); const allGroupedRows = useMemo( - // Drop automatic context-usage snapshots before they become flex rows: an - // empty (null-rendered) row still consumes a `--chat-row-gap` on each side, - // so leaving them in would stack blank gaps during a streaming turn. - () => groupChatTranscriptRows(rows).filter((row) => !isAutomaticContextUsageEvent(row.event)), + // Drop automatic context-usage snapshots and same-provider "handoffs" + // before they become flex rows: an empty (null-rendered) row still consumes + // a `--chat-row-gap` on each side, so leaving them in would stack blank + // gaps during a streaming turn. + () => + groupChatTranscriptRows(rows).filter( + (row) => + !isAutomaticContextUsageEvent(row.event) && !isSameProviderModelHandoffEvent(row.event), + ), [rows], ); // Same lookup-map shape as turnProofByRowKey / turnEndDurationByRowKey rather diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 121ddaa159..1c65784dbb 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -269,10 +269,12 @@ func workModelHandoffNoticeDetail(fromProvider: String, toProvider: String) -> S } /// Inverse of `workModelHandoffNoticeDetail`. Returns nil when either half is -/// missing so a malformed row is dropped rather than drawn half-empty. +/// missing — or when the detail carries anything other than exactly two +/// pipe-separated halves — so a malformed row is dropped rather than drawn +/// half-empty. func workModelHandoffProviders(fromDetail detail: String?) -> (from: String, to: String)? { guard let detail else { return nil } - let parts = detail.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + let parts = detail.split(separator: "|", omittingEmptySubsequences: false) guard parts.count == 2 else { return nil } let from = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) let to = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3d7ff78f86..7dbbf2fa12 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5130,6 +5130,10 @@ final class ADETests: XCTestCase { """) XCTAssertFalse(sameProvider.isEmpty, "The transcript row itself still parses.") XCTAssertTrue(buildWorkEventCards(from: sameProvider).isEmpty) + + // A detail carrying more than the two packed halves is malformed, not a + // handoff with a stray suffix. + XCTAssertNil(workModelHandoffProviders(fromDetail: "claude|codex|extra")) } /// The new-chat header sheds branding, then the usage carousel, then the