Skip to content
Merged
86 changes: 40 additions & 46 deletions ios/App/Cards/SkillExecutionReceiptView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import CompanionCore

/// One tool call: what it was, how it went, and — when you open it — what it
/// was given and what it said back.
Expand All @@ -16,7 +17,7 @@ import SwiftUI
/// from the system palette.
public struct SkillExecutionReceiptView: View {
public let skillName: String
public let status: String // "running", "success", "error"
public let status: String
public let durationMs: Int
public let parameters: String
public let output: String
Expand All @@ -38,46 +39,36 @@ public struct SkillExecutionReceiptView: View {
self.output = output
}

/// The three states a tool call can be in, as one shape. Derived from
/// the same `status` string the transcript carries, so a card cannot say
/// "Running" in its badge and draw itself as a failure.
private enum State {
case running, success, failure
/// Parsed in CompanionCore. Done and Failed are only those outcomes.
/// Cancelled or stopped reads as Stopped. Anything else stays Running.
private var state: SkillReceiptStatus { SkillReceiptStatus(status) }

init(_ raw: String) {
switch raw {
case "running": self = .running
case "error": self = .failure
default: self = .success
}
}

var label: String {
switch self {
case .running: return "Running"
case .success: return "Done"
case .failure: return "Failed"
}
}

var tint: Color {
switch self {
case .running: return .accentColor
case .success: return .green
case .failure: return .red
}
private var stateTint: Color {
switch state {
case .running:
return .accentColor
case .success:
return .green
case .failure:
return .red
case .stopped:
return Color.secondary
}
}

var systemImage: String {
switch self {
case .running: return "circle.dotted"
case .success: return "checkmark.circle.fill"
case .failure: return "exclamationmark.triangle.fill"
}
private var stateSymbol: String {
switch state {
case .running:
return "circle.dotted"
case .success:
return "checkmark.circle.fill"
case .failure:
return "exclamationmark.triangle.fill"
case .stopped:
return "stop.circle"
}
}

private var state: State { State(status) }
private var hasDetails: Bool { !parameters.isEmpty || !output.isEmpty }

/// One sentence for the whole row. Without it VoiceOver read the tool
Expand Down Expand Up @@ -169,14 +160,17 @@ public struct SkillExecutionReceiptView: View {
)
}

/// Only a failure earns a coloured edge. A run that succeeded or is still
/// running gets a neutral one, so a transcript full of completed tool
/// calls does not read as a wall of alerts.
/// Only a failure earns a coloured edge. Success and Stopped stay
/// neutral, so a transcript full of finished tool calls does not read
/// as a wall of alerts.
private var borderTint: Color {
switch state {
case .failure: return .red.opacity(0.4)
case .running: return .accentColor.opacity(0.35)
case .success: return Color.secondary.opacity(0.18)
case .failure:
return .red.opacity(0.4)
case .running:
return .accentColor.opacity(0.35)
case .success, .stopped:
return Color.secondary.opacity(0.18)
}
}

Expand Down Expand Up @@ -252,19 +246,19 @@ private var statusBadge: some View {
if state == .running {
ProgressView()
.controlSize(.mini)
.tint(state.tint)
.tint(stateTint)
} else {
Image(systemName: state.systemImage)
Image(systemName: stateSymbol)
.font(.scaled(11, weight: .bold, relativeTo: .caption))
.foregroundStyle(state.tint)
.foregroundStyle(stateTint)
}
Text(state.label)
.font(.scaled(12, weight: .semibold, relativeTo: .caption))
.foregroundStyle(state.tint)
.foregroundStyle(stateTint)
}
.padding(.horizontal, 8)
.frame(height: 22)
.background(Capsule().fill(state.tint.opacity(0.14)))
.background(Capsule().fill(stateTint.opacity(0.14)))
.accessibilityHidden(true)
}
}
108 changes: 95 additions & 13 deletions ios/App/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import CompanionCore
// isn't. The App target is iOS; CompanionCore is where the portable half
// lives.
import UIKit
import ImageIO
import AVFoundation
import PhotosUI
import UniformTypeIdentifiers
Expand Down Expand Up @@ -1254,7 +1255,7 @@ struct ChatView: View {
// steering; keep the capture locally rather than upload an orphan.
guard recording == nil || !current.busy else { return nil }
draft = ""
pendingAttachments = []
clearPendingAttachments()
dictation.discardRecording()
showCommandHUD = false
// Sending is the reader asking to be at the newest message again,
Expand Down Expand Up @@ -1296,7 +1297,7 @@ struct ChatView: View {
transcript: transcript
) else { return }
if draft.isEmpty { draft = sentText }
if pendingAttachments.isEmpty { pendingAttachments = outgoing }
if pendingAttachments.isEmpty { replacePendingAttachments(outgoing) }
if let recording, dictation.recordedWAV == nil {
dictation.restoreRecording(recording.data, transcript: recording.transcript)
}
Expand Down Expand Up @@ -1362,12 +1363,12 @@ struct ChatView: View {
private func restoreQueuedDraft(from item: QueuedSend) {
if let rawText = item.rawText {
draft = rawText
pendingAttachments = item.attachments
replacePendingAttachments(item.attachments)
} else {
// Legacy rows only stored composed wire text; re-applying chips would
// duplicate markers already embedded in `text`.
draft = item.text
pendingAttachments = []
clearPendingAttachments()
}
composerFocused = true
}
Expand Down Expand Up @@ -1572,8 +1573,7 @@ struct ChatView: View {
HStack(spacing: 8) {
ForEach(pendingAttachments) { item in
HStack(spacing: 6) {
Image(systemName: item.kind == .image ? "photo" : "doc")
.font(.system(size: 13, weight: .semibold))
PendingAttachmentGlyph(item: item)
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
.font(.subheadline.weight(.medium))
Expand All @@ -1583,6 +1583,7 @@ struct ChatView: View {
.foregroundStyle(Color.secondary)
}
Button {
ComposerPendingThumbnailCache.remove(id: item.id)
pendingAttachments.removeAll { $0.id == item.id }
} label: {
Image(systemName: "xmark.circle.fill")
Expand All @@ -1591,9 +1592,10 @@ struct ChatView: View {
.buttonStyle(.plain)
.accessibilityLabel("Remove \(item.name)")
}
.padding(.leading, 10)
.padding(.leading, 8)
.padding(.trailing, 8)
.padding(.vertical, 8)
.padding(.vertical, 6)
.frame(minHeight: 44)
.background(Capsule().fill(Color.secondary.opacity(0.14)))
}
}
Expand Down Expand Up @@ -1984,6 +1986,64 @@ struct ChatView: View {
}
pendingAttachments.append(PendingChatAttachment(name: name, mime: resolved, data: data))
}

/// Drop decoded thumbs for attachments that leave the strip.
private func clearPendingAttachments() {
replacePendingAttachments([])
}

private func replacePendingAttachments(_ next: [PendingChatAttachment]) {
let kept = Set(next.map(\.id))
for item in pendingAttachments where !kept.contains(item.id) {
ComposerPendingThumbnailCache.remove(id: item.id)
}
ComposerPendingThumbnailCache.pruneTombstones(keeping: kept)
for item in next {
ComposerPendingThumbnailCache.reinstate(id: item.id)
}
pendingAttachments = next
}
}

/// The mark on a pending attachment. A photo uses the picture itself.
/// A file, or an image the phone cannot decode, keeps an icon.
private struct PendingAttachmentGlyph: View {
let item: PendingChatAttachment
@State private var thumbnail: UIImage?

var body: some View {
Group {
if let image = thumbnail {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.accessibilityHidden(true)
} else {
Image(systemName: item.kind == .image ? "photo" : "doc")
.font(.system(size: 13, weight: .semibold))
.frame(width: 36, height: 36)
.accessibilityHidden(true)
}
}
.task(id: item.id) {
let requestedId = item.id
thumbnail = nil
let scale = UIScreen.main.scale
guard let thumb = await ComposerPendingThumbnailCache.thumbnail(for: item, screenScale: scale) else {
return
}
guard ComposerThumbnailCachePolicy.shouldApplyThumbnailToRow(
requestedId: requestedId,
rowId: item.id,
isCancelled: Task.isCancelled
) else {
return
}
thumbnail = UIImage(cgImage: thumb.cgImage)
}
}
}

struct MessageRow: View {
Expand Down Expand Up @@ -2766,16 +2826,37 @@ struct ActivityChip: View {
if let tool {
SkillExecutionReceiptView(
skillName: tool.name,
status: tool.ok.map { $0 ? "success" : "error" } ?? "running",
status: ToolStepStatus(ok: tool.ok).receiptStatus,
durationMs: tool.durationMs ?? 0,
parameters: tool.target ?? "",
output: tool.detail ?? ""
)
.padding(.leading, 2)
.modifier(ChildStepInset(parentItemId: tool.parentItemId))
}
}
}

/// A child step is indented from the bot's own steps, with a rule on its
/// left, the same idea as the desktop transcript. The words on the row
/// stay Done, Running, or Failed.
private struct ChildStepInset: ViewModifier {
let parentItemId: String?

func body(content: Content) -> some View {
content
.padding(.leading, parentItemId == nil ? 2 : 18)
.overlay(alignment: .leading) {
if parentItemId != nil {
Capsule()
.fill(Color.secondary.opacity(0.45))
.frame(width: 2)
.padding(.vertical, 10)
.accessibilityHidden(true)
}
}
}
}

/// An interactive folded summary card for a run of tool calls.
/// Shows a live summary statement (e.g. "14 tool calls • run_command ×10, view_file, call_mcp_tool")
/// and expands on tap to display the full individual skill execution receipts.
Expand Down Expand Up @@ -2803,7 +2884,7 @@ struct ActivityRunView: View {
} else if hasFailure {
parts.append("\(desc.failedCount) failed")
} else {
parts.append("Complete")
parts.append("Done")
}
return parts.joined(separator: ", ")
}
Expand Down Expand Up @@ -2869,7 +2950,7 @@ struct ActivityRunView: View {
.foregroundStyle(Color.red)
.lineLimit(1)
} else if !isRunning {
Text("• Complete")
Text("• Done")
.font(.scaled(12, weight: .medium, relativeTo: .caption))
.foregroundStyle(Color.green)
.lineLimit(1)
Expand Down Expand Up @@ -2927,11 +3008,12 @@ struct ActivityRunView: View {
if let tool = msg.tool {
SkillExecutionReceiptView(
skillName: tool.name,
status: tool.ok.map { $0 ? "success" : "error" } ?? "running",
status: ToolStepStatus(ok: tool.ok).receiptStatus,
durationMs: tool.durationMs ?? 0,
parameters: tool.target ?? "",
output: tool.detail ?? ""
)
.modifier(ChildStepInset(parentItemId: tool.parentItemId))
}
}
}
Expand Down
32 changes: 23 additions & 9 deletions ios/App/Composer/PredictiveActionChipsView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CompanionCore
import SwiftUI

public struct ActionChipItem: Identifiable {
Expand All @@ -20,12 +21,9 @@ public struct PredictiveActionChipsView: View {

@Environment(\.colorScheme) private var colorScheme

public static let defaultChips: [ActionChipItem] = [
ActionChipItem(title: "Show diff", icon: "arrow.triangle.pull", prompt: "Show latest git diff"),
ActionChipItem(title: "Run tests", icon: "checkmark.seal", prompt: "Run all automated tests"),
ActionChipItem(title: "Explain steps", icon: "text.bubble", prompt: "Explain the changes in detail"),
ActionChipItem(title: "What's next?", icon: "sparkles", prompt: "What should we do next?")
]
/// Empty on purpose. Chips that only paste a sentence are not offered.
/// A real command belongs on the slash list, not here.
public static let defaultChips: [ActionChipItem] = []

public init(
chips: [ActionChipItem] = PredictiveActionChipsView.defaultChips,
Expand All @@ -37,12 +35,28 @@ public struct PredictiveActionChipsView: View {
self.onSelectChip = onSelectChip
}

/// Drops a chip that would only paste a canned sentence, even if a caller
/// still passes one of the old defaults.
private var offeredChips: [ActionChipItem] {
chips.filter { !ComposerPromptChips.isCannedSnippet(title: $0.title, prompt: $0.prompt) }
}

@ViewBuilder
public var body: some View {
let offered = offeredChips
if offered.isEmpty {
EmptyView()
} else {
chipRow(offered)
}
}

private func chipRow(_ offered: [ActionChipItem]) -> some View {
let isDark = colorScheme == .dark
ScrollView(.horizontal, showsIndicators: false) {

return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(chips) { chip in
ForEach(offered) { chip in
Button {
onSelectChip(chip)
Haptics.selection()
Expand Down
Loading
Loading