diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 1c1522e44..69ca29405 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -421,8 +421,7 @@ struct WorkToolCallsPanelView: View { .accessibilityLabel("Tool calls cluster, \(group.count) calls, \(isExpanded ? "expanded" : "collapsed")") } - /// Same grammar as every other collapsed card row: leading glyph, one-line - /// summary, right-aligned count, trailing chevron. + /// Compact centered summary with the full-width button retaining its hit area. private var header: some View { Button(action: onToggle) { HStack(alignment: .center, spacing: 6) { @@ -433,33 +432,15 @@ struct WorkToolCallsPanelView: View { Text("Tool calls") .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textMuted) - if !isExpanded, let latest = group.latest { - WorkToolStatusGlyph(status: latest.status) - Text(memberSlug(latest)) - .font(.caption2.monospaced().weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - if let target = memberTarget(latest), !target.isEmpty { - Text(target) - .font(.caption) - .foregroundStyle(ADEColor.textPrimary.opacity(0.88)) - .lineLimit(1) - .truncationMode(.tail) - } - } - Spacer(minLength: 6) Text("\(group.count)") - .font(.caption.weight(.semibold).monospacedDigit()) + .font(.caption.weight(.medium).monospacedDigit()) .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background(ADEColor.textMuted.opacity(0.10), in: Capsule(style: .continuous)) Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(ADEColor.textMuted) } .padding(.vertical, 2) - .frame(minHeight: 44) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -673,22 +654,15 @@ struct WorkChangedFilesPanelView: View { Text("Files changed") .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textMuted) - if !isExpanded { - collapsedPreview - } - Spacer(minLength: 6) Text("\(group.count)") - .font(.caption.weight(.semibold).monospacedDigit()) + .font(.caption.weight(.medium).monospacedDigit()) .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background(ADEColor.textMuted.opacity(0.10), in: Capsule(style: .continuous)) Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(ADEColor.textMuted) } .padding(.vertical, 2) - .frame(minHeight: 44) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -710,32 +684,6 @@ struct WorkChangedFilesPanelView: View { } } - @ViewBuilder - private var collapsedPreview: some View { - if group.hasRunning { - Circle() - .fill(ADEColor.warning.opacity(0.85)) - .frame(width: 6, height: 6) - } - if group.totalAdditions > 0 { - Text("+\(group.totalAdditions)") - .font(.caption2.monospacedDigit()) - .foregroundStyle(ADEColor.success.opacity(0.85)) - } - if group.totalDeletions > 0 { - Text("−\(group.totalDeletions)") - .font(.caption2.monospacedDigit()) - .foregroundStyle(ADEColor.danger.opacity(0.85)) - } - if let latest = group.files.last { - Text(workReferenceLabel(for: latest.path)) - .font(.caption) - .foregroundStyle(ADEColor.textPrimary.opacity(0.88)) - .lineLimit(1) - .truncationMode(.middle) - } - } - /// Long-press peek for the collapsed cluster: the file list it would reveal, /// without moving the transcript. private var collapsedPeekBody: some View { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 5151d3254..31fd9aaae 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -584,6 +584,7 @@ struct WorkChatSummaryRenderContext: Equatable { struct WorkChatSessionRenderContext: Equatable { let id: String let laneId: String + let providerFallback: String? let chatIdleSinceAt: String? let endedAt: String? let lastOutputPreview: String? @@ -596,6 +597,7 @@ struct WorkChatSessionRenderContext: Equatable { init(_ session: TerminalSessionSummary) { self.id = session.id self.laneId = session.laneId + self.providerFallback = workChatProviderFamilyFromToolType(session.toolType) self.chatIdleSinceAt = session.chatIdleSinceAt self.endedAt = session.endedAt self.lastOutputPreview = session.lastOutputPreview @@ -604,13 +606,15 @@ struct WorkChatSessionRenderContext: Equatable { } } -private struct WorkChatSummaryTimelineKey: Equatable { +struct WorkChatSummaryTimelineKey: Equatable { let provider: String + let providerFallback: String? let model: String let modelId: String? - init(_ context: WorkChatSummaryRenderContext) { + init(_ context: WorkChatSummaryRenderContext, providerFallback: String? = nil) { self.provider = context.provider + self.providerFallback = providerFallback self.model = context.model self.modelId = context.modelId } @@ -829,7 +833,10 @@ struct WorkChatSessionView: View { } private var chatSummaryTimelineKey: WorkChatSummaryTimelineKey { - WorkChatSummaryTimelineKey(chatSummaryContext) + WorkChatSummaryTimelineKey( + chatSummaryContext, + providerFallback: session.providerFallback + ) } private var selectedSubagentSnapshot: WorkSubagentSnapshot? { @@ -1152,7 +1159,11 @@ struct WorkChatSessionView: View { if rebuildToolActivityIndex { turnToolActivity = workTurnToolActivityIndex(from: timeline) } - let presentedTimeline = workPresentedTimelineEntries(timeline) + let summaryProvider = chatSummaryContext.provider.trimmingCharacters(in: .whitespacesAndNewlines) + let presentedTimeline = workPresentedTimelineEntries( + timeline, + provider: summaryProvider.isEmpty ? session.providerFallback : summaryProvider + ) var budgetFloors = assistantBudgetFloors var nextPresentation = makeWorkTimelinePresentation( timeline: presentedTimeline, @@ -1444,7 +1455,7 @@ struct WorkChatSessionView: View { .font(.footnote.weight(.semibold)) } - if timeline.isEmpty { + if timelinePresentation.timelineCount == 0 { transcriptEmptyStateSection } else { let streamingMessageId = streamingAssistantMessageId @@ -2021,15 +2032,16 @@ struct WorkChatSessionView: View { /// Timeline/scroll change handlers, split from `body` for type-checker budget. private func timelineScrollHandlers(_ content: V, proxy: ScrollViewProxy) -> some View { content - .onChange(of: timeline.count) { oldCount, newCount in + .onChange(of: timelinePresentation.timelineCount) { oldCount, newCount in let previousTailId = lastTimelineTailId - lastTimelineTailId = timeline.last?.id + let nextTailId = timelinePresentation.timelineLastId + lastTimelineTailId = nextTailId let delta = newCount - oldCount guard delta > 0 else { return } // Older-page prepends grow the timeline above the viewport — the // newest entry stays put. Don't autoscroll to the bottom or flag // the prepended entries as "new messages below". - if let previousTailId, previousTailId == timeline.last?.id { + if let previousTailId, previousTailId == nextTailId { return } if isNearBottom { @@ -2045,7 +2057,7 @@ struct WorkChatSessionView: View { } } } - .onChange(of: timeline.last?.id) { oldTailId, newTailId in + .onChange(of: timelinePresentation.timelineLastId) { oldTailId, newTailId in guard oldTailId != newTailId else { return } lastTimelineTailId = newTailId guard oldTailId != nil, newTailId != nil, isNearBottom else { return } diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index b9f7cd8fc..41803d17b 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -981,8 +981,6 @@ struct WorkToolGroupModel: Identifiable, Equatable { let id: String let members: [WorkToolGroupMember] - var hasRunning: Bool { members.contains { $0.status == .running } } - var latest: WorkToolGroupMember? { members.last } var count: Int { members.count } } @@ -1004,10 +1002,7 @@ struct WorkChangedFilesGroupModel: Identifiable, Equatable { let id: String let files: [WorkChangedFileEntry] - var hasRunning: Bool { files.contains { $0.status == .running } } var count: Int { files.count } - var totalAdditions: Int { files.reduce(0) { $0 + $1.additions } } - var totalDeletions: Int { files.reduce(0) { $0 + $1.deletions } } } struct WorkTurnSeparator: Equatable { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index d4dd476eb..6cade52eb 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -626,21 +626,6 @@ func workChatHasOlderTranscriptHistory( return allowsCanonicalFallback && (canonicalTranscriptCursor ?? 0) > 0 } -private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { - let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - guard !raw.isEmpty else { return nil } - if raw == "cursor" || raw.hasPrefix("cursor") { return "cursor" } - if raw.hasPrefix("claude") { return "claude" } - if raw.hasPrefix("codex") { return "codex" } - if raw.hasPrefix("opencode") { return "opencode" } - if raw.hasPrefix("droid") || raw.hasPrefix("factory") { return "droid" } - if raw.hasPrefix("qwen") { return "qwen" } - if raw.hasPrefix("kimi") { return "kimi" } - if raw.hasPrefix("grok") { return "grok" } - if raw.hasPrefix("copilot") { return "copilot" } - return raw -} - struct WorkSessionDestinationView: View { @EnvironmentObject var syncService: SyncService /// Observed so a mute toggled anywhere (Work-list row menu, settings) flows diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index dd9878914..320772d00 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -731,6 +731,15 @@ func providerFamilyKey(_ provider: String) -> String { return raw } +/// Resolve a terminal session's tool type through the canonical provider-family +/// mapping used by the rest of the Work surface. Empty tool types stay unknown +/// so a missing summary cannot accidentally opt into a provider-specific UI. +func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { + let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !raw.isEmpty else { return nil } + return providerFamilyKey(raw) +} + /// Collapse a free-form provider key to a chat-capable runtime family. /// Routed Pi models must stay on Pi rather than falling through to Claude. func workNormalizedChatProvider(_ provider: String) -> String { diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index c20ca40f4..a7022489e 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -1829,20 +1829,28 @@ private func isInterruptStoppedSubagentResultEntry(_ entry: WorkTimelineEntry) - /// The rows the transcript actually draws, from the rows the timeline holds. /// -/// This is the seam where a presentation-only rule belongs — and for a while it -/// held one that swallowed whole turns: every normalized `.toolGroup` row was -/// dropped here, so a turn whose only work was one `Read` and one approved shell -/// command rendered no trace of either. The turn-end marker's 8pt chevron was -/// the sole way back to them. +/// This is the seam where presentation-only rules belong. Tool and file-change +/// clusters stay visible as compact rows, while low-signal activity summaries +/// are omitted from the phone transcript without changing the raw timeline. /// -/// That filter dated from when a cluster had no compact form and N stacked tool -/// cards ate the phone viewport. A finished cluster is now a single 44pt row in -/// the same one-liner grammar `WorkChangedFilesPanelView` already uses right -/// beside it, so there is nothing left to protect the viewport from — and -/// hiding tool calls while showing file changes made the transcript disagree -/// with itself about what a cluster is. -func workPresentedTimelineEntries(_ timeline: [WorkTimelineEntry]) -> [WorkTimelineEntry] { - timeline +/// Desktop and Chat Info still retain the underlying activity events, so this +/// remains a mobile transcript presentation choice rather than a sync change. +func workPresentedTimelineEntries( + _ timeline: [WorkTimelineEntry], + provider: String? = nil +) -> [WorkTimelineEntry] { + let hidesPromptSuggestions = provider.map { providerFamilyKey($0) == "claude" } == true + return timeline.filter { entry in + guard case .eventCard(let card) = entry.payload else { return true } + switch card.kind { + case "activity", "activityBundle", "todo": + return false + case "promptSuggestion": + return !hidesPromptSuggestions + default: + return true + } + } } /// Fold tool-like timeline entries (tool cards, commands, file changes) into diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9887641a7..a4fbf8a7f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -15007,6 +15007,43 @@ final class ADETests: XCTestCase { XCTAssertTrue(activityBundles[1].body?.contains("Second turn cron") == true) } + func testWorkTimelineOmitsPromptSuggestionsForClaudeButPreservesOtherProviders() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:00.000Z","sequence":1,"event":{"type":"todo_update","turnId":"turn-1","items":[{"id":"task-1","description":"Review mobile activity rows","status":"in_progress"}]}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":2,"event":{"type":"scheduled_work_update","id":"cron-1","kind":"cron","status":"scheduled","origin":"schedule_cron","title":"CI follow-up","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:02.000Z","sequence":3,"event":{"type":"prompt_suggestion","suggestion":"Keep going till it is merged","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:03.000Z","sequence":4,"event":{"type":"text","text":"Visible answer","turnId":"turn-1"}} + """ + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: parseWorkChatTranscript(raw), + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let rawCards = snapshot.timeline.compactMap { entry -> WorkEventCardModel? in + guard case .eventCard(let card) = entry.payload else { return nil } + return card + } + let claudePresented = workPresentedTimelineEntries(snapshot.timeline, provider: "claude") + let codexPresented = workPresentedTimelineEntries(snapshot.timeline, provider: "codex") + + XCTAssertTrue(rawCards.contains { $0.kind == "activityBundle" }) + XCTAssertTrue(rawCards.contains { $0.kind == "promptSuggestion" }) + XCTAssertTrue(claudePresented.contains { entry in + guard case .message(let message) = entry.payload else { return false } + return message.markdown == "Visible answer" + }) + XCTAssertFalse(claudePresented.contains { entry in + guard case .eventCard(let card) = entry.payload else { return false } + return ["activity", "activityBundle", "todo", "promptSuggestion"].contains(card.kind) + }) + XCTAssertTrue(codexPresented.contains { entry in + guard case .eventCard(let card) = entry.payload else { return false } + return card.kind == "promptSuggestion" + }) + } + func testParseWorkChatTranscriptAppliesTranscriptRetractionsByMessageId() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":1,"event":{"type":"text","text":"Superseded answer","messageId":"provider-message-1","turnId":"turn-1"}} @@ -24895,13 +24932,30 @@ final class ADETests: XCTestCase { XCTAssertEqual(toolGroups.count, 1) XCTAssertEqual(toolGroups.first?.members.count, 2) XCTAssertTrue(standaloneToolCards.isEmpty) - guard case .tool(let latest)? = toolGroups.first?.latest else { + guard case .tool(let latest)? = toolGroups.first?.members.last else { return XCTFail("Expected the latest visible group member to be the newest tool call.") } XCTAssertEqual(latest.id, "tool-2") XCTAssertEqual(latest.status, .running) } + func testWorkChatSessionContextFallsBackToClaudeProviderFromToolType() { + let context = WorkChatSessionRenderContext( + makeTerminalSessionSummary(toolType: "claude-chat") + ) + + XCTAssertEqual(context.providerFallback, "claude") + } + + func testWorkChatSummaryTimelineKeyIncludesProviderFallback() { + let context = WorkChatSummaryRenderContext(nil) + + let claudeKey = WorkChatSummaryTimelineKey(context, providerFallback: "claude") + let codexKey = WorkChatSummaryTimelineKey(context, providerFallback: "codex") + + XCTAssertNotEqual(claudeKey, codexKey) + } + func testBuildWorkTimelineCollapsesAlternatingReasoningAndToolBursts() { let transcript: [WorkChatEnvelope] = [ WorkChatEnvelope( diff --git a/apps/ios/ADETests/WorkCardExpansionTests.swift b/apps/ios/ADETests/WorkCardExpansionTests.swift index 0c42f9c0a..16980acfd 100644 --- a/apps/ios/ADETests/WorkCardExpansionTests.swift +++ b/apps/ios/ADETests/WorkCardExpansionTests.swift @@ -359,6 +359,23 @@ final class WorkCardExpansionTests: XCTestCase { XCTAssertTrue(hasToolCluster, "so read-only clusters have to render too") } + func testPresentationHidesMobileActivityOnlyRows() { + let entries = [ + eventCardEntry(id: "activity", kind: "activity"), + eventCardEntry(id: "activity-bundle", kind: "activityBundle"), + eventCardEntry(id: "todo", kind: "todo"), + message("visible", role: "assistant", markdown: "Visible answer"), + ] + + let presented = workPresentedTimelineEntries(entries) + + XCTAssertEqual(presented.map(\.id), ["message-visible"]) + XCTAssertFalse(presented.contains { entry in + guard case .eventCard(let card) = entry.payload else { return false } + return ["activity", "activityBundle", "todo"].contains(card.kind) + }) + } + /// Nothing streams in a reopened chat, so the cluster lands on its own default /// — the one-line row, not the member list. func testFinishedToolClusterDefaultsToItsCollapsedRow() { @@ -487,6 +504,27 @@ final class WorkCardExpansionTests: XCTestCase { ) } + private func eventCardEntry(id: String, kind: String) -> WorkTimelineEntry { + WorkTimelineEntry( + id: id, + timestamp: "2026-01-01T00:00:00.000Z", + rank: 0, + payload: .eventCard( + WorkEventCardModel( + id: id, + kind: kind, + title: kind, + icon: "sparkles", + tint: .accent, + timestamp: "2026-01-01T00:00:00.000Z", + body: nil, + bullets: [], + metadata: [] + ) + ) + ) + } + private func adeCard(_ json: String) throws -> WorkAdeCardModel { let payload = try JSONDecoder().decode( AgentChatAdeCardPayload.self, diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 3965dbbf4..1c1101cdc 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -401,9 +401,11 @@ apps/ios/ │ │ │ # (provider session browse/details, │ │ │ # lane picker, Continue/Copy policy), │ │ │ # WorkLanePickerDropdown, -│ │ │ # WorkChatRichCardViews (de-glassed -│ │ │ # tool-call / work-log / command / -│ │ │ # file-change transcript cards, inline +│ │ │ # WorkChatRichCardViews (de-glassed, +│ │ │ # centered compact tool-call / +│ │ │ # file-change summary rows that expand +│ │ │ # to inline details, plus work-log / +│ │ │ # command transcript cards, inline │ │ │ # subagent spawn/result/background-chip │ │ │ # timeline rows, plus the unified Chat │ │ │ # rich `ade_card` rows (duration, @@ -636,7 +638,11 @@ The Work model/activity parity path is concentrated in these files: structured web-search `results`, threaded onto the tool card's `Sources` chips and deduped against the action URLs), plus the Work context meter's provider-neutral measured/compacting/recalculating/unknown reduction across - live and persisted history. `WorkTimelineHelpers.swift` also owns + live and persisted history. `WorkTimelineHelpers.swift` also owns the + provider-aware `workPresentedTimelineEntries` filter: compact tool/file + groups stay visible, activity/task-update ribbons stay out of the mobile + thread, and Claude prompt suggestions stay out of the visible Claude + transcript while remaining in the raw timeline. It also owns `workPendingInputResolutions`, the first-receipt-wins `itemId` → resolution word map that gives question / plan / approval cards their inline outcome. `WorkErrorAndMessageHelpers.swift` owns the pending-input gate derivation — @@ -3228,17 +3234,18 @@ the stats and shows update guidance. provider-specific cards, `WorkToolCallsPanelView` clusters and `WorkChangedFilesPanelView` rows in chronological order; `workPresentedTimelineEntries` in `WorkTimelineHelpers.swift` is the seam that - decides what reaches the visible timeline. It used to drop every normalized - `toolGroup` row, which meant a turn whose only work was a `Read` and an - approved shell command left no trace in the transcript at all. That rule was - written when a cluster had no compact form; a finished cluster is now a single - 44pt row in the same one-liner grammar the changed-files panel uses, so it - stays. The live `WorkActivityIndicator` and each `WorkTurnEndMarkerView` still - open the whole turn's activity in `WorkTurnActivitySheet`, where tapping a - member reveals its result or output. The live row uses `ViewThatFits` so - narrow phones retain the activity verb and monospaced elapsed time without - squeezing tool details into the same line. The association is data-driven and - never invents file changes for providers that did not emit them. + decides what reaches the visible timeline. Read-only tool clusters and file + changes stay visible as centered, compact `Tool calls N` / `Files changed N` + rows; tapping either row still opens the full member or file list, and tapping + a member still reveals its result, output, or diff. Mobile-only activity and + task-update ribbons are omitted from the thread, while scheduled-work state + remains available in Chat Info. Claude-only prompt-suggestion ribbons are + also omitted from the visible Claude transcript while their underlying events + remain available to the raw timeline. + The live `WorkActivityIndicator` and each `WorkTurnEndMarkerView` still open + the whole turn's activity in `WorkTurnActivitySheet`. The association is + data-driven and never invents file changes for providers that did not emit + them. - **A swept pending-input gate is marked, not deleted, and the host decides.** The phone mirrors desktop's split (see the "Pending input derivation" entry in [Chat](../chat/README.md#fragile-and-tricky-wiring)) because it had the same