Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 5 additions & 57 deletions apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
30 changes: 21 additions & 9 deletions apps/ios/ADE/Views/Work/WorkChatSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -829,7 +833,10 @@ struct WorkChatSessionView: View {
}

private var chatSummaryTimelineKey: WorkChatSummaryTimelineKey {
WorkChatSummaryTimelineKey(chatSummaryContext)
WorkChatSummaryTimelineKey(
chatSummaryContext,
providerFallback: session.providerFallback
)
}

private var selectedSubagentSnapshot: WorkSubagentSnapshot? {
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var budgetFloors = assistantBudgetFloors
var nextPresentation = makeWorkTimelinePresentation(
timeline: presentedTimeline,
Expand Down Expand Up @@ -1444,7 +1455,7 @@ struct WorkChatSessionView: View {
.font(.footnote.weight(.semibold))
}

if timeline.isEmpty {
if timelinePresentation.timelineCount == 0 {
transcriptEmptyStateSection
} else {
let streamingMessageId = streamingAssistantMessageId
Expand Down Expand Up @@ -2021,15 +2032,16 @@ struct WorkChatSessionView: View {
/// Timeline/scroll change handlers, split from `body` for type-checker budget.
private func timelineScrollHandlers<V: View>(_ 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 {
Expand All @@ -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 }
Expand Down
5 changes: 0 additions & 5 deletions apps/ios/ADE/Views/Work/WorkModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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 {
Expand Down
15 changes: 0 additions & 15 deletions apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 21 additions & 13 deletions apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion apps/ios/ADETests/ADETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading