iOS remote: transcript fidelity + mid-session controls & model catalog
Bring NucleicRemote closer to desktop parity in two areas (the core sync loop was already at parity — shared protocol, control scope). Transcript fidelity (iOS): a client-side TranscriptProjection coalesces streaming text by messageID and folds each tool call's lifecycle (start/deltas/complete/result/fileChange) into one expandable card — fixing the duplicate started+completed rows. Adds Markdown bubbles, the gold Orchestra card for Task/Agent spawns, and the previously-dropped usage/cost, rate-limit, file-change, turn-boundary and session-started rows, plus a context-window % header badge. Mid-session controls + model catalog (protocol/host/iOS): project the host ModelCatalog over the wire as WireModelCatalog (in Welcome); add 5 control-scope setters (setSessionModel/Effort/Auto/AutoShip/ShipBranch) backed by the existing AppStore.mutateSession + SessionController hooks; enrich WireSessionSummary with model/effort/auto/autoShip/shipBranch/ contextInputTokens (all forward-compatible). The composer gains a model picker and a catalog-driven effort menu (per-backend caps: Codex→xhigh, Grok→auto), and the session header gains a model/effort/auto/autoship control bar. Tests: CBOR round-trips for the new messages, Welcome.modelCatalog, the new summary fields, forward-compat decode of old bytes, and the setters reaching the host. Verified in the Simulator (NUCLEIC_DEMO=1). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e3b6ccc297
commit
e6ef59e830
@@ -8,12 +8,14 @@ struct StartChatComposer: View {
|
||||
@State private var projectID: ProjectID?
|
||||
@State private var draft = ""
|
||||
@State private var auto = false
|
||||
@State private var model: String?
|
||||
@State private var effort = MobileEfforts.fallback
|
||||
|
||||
private var projects: [WireProject] { store.dashboard.projects }
|
||||
private var selected: WireProject? {
|
||||
projects.first { $0.id == projectID } ?? projects.first
|
||||
}
|
||||
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -28,7 +30,6 @@ struct StartChatComposer: View {
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, controlled: selected?.isNucleicControlled ?? false)
|
||||
Toggle(isOn: $auto) {
|
||||
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
|
||||
}
|
||||
@@ -36,14 +37,20 @@ struct StartChatComposer: View {
|
||||
.tint(Palette.accent)
|
||||
.font(.caption)
|
||||
}
|
||||
HStack {
|
||||
// The model also selects the backend, so the home composer offers every provider.
|
||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Describe a task…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && (selected?.isNucleicControlled ?? false))
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
||||
Button {
|
||||
if let project = selected {
|
||||
store.startChat(in: project.id, message: draft, effort: effort, auto: auto)
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
|
||||
draft = ""
|
||||
}
|
||||
} label: {
|
||||
@@ -56,6 +63,14 @@ struct StartChatComposer: View {
|
||||
}
|
||||
}
|
||||
.card()
|
||||
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
|
||||
.onChange(of: model) { _, newModel in
|
||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
||||
effort = levels.last ?? MobileEfforts.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ struct ProjectDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let project: WireProject
|
||||
@State private var draft = ""
|
||||
@State private var model: String?
|
||||
@State private var effort = MobileEfforts.fallback
|
||||
|
||||
private var sessions: [WireSessionSummary] {
|
||||
@@ -60,15 +61,17 @@ struct ProjectDetailView: View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
EffortMenu(effort: $effort, controlled: project.isNucleicControlled)
|
||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model,
|
||||
controlled: project.isNucleicControlled)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder).lineLimit(1...4)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
|
||||
Button {
|
||||
store.startChat(in: project.id, message: draft, effort: effort)
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort)
|
||||
draft = ""
|
||||
} label: { Image(systemName: "arrow.up.circle.fill").font(.title2) }
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||
@@ -88,5 +91,12 @@ struct ProjectDetailView: View {
|
||||
}
|
||||
.navigationTitle(project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: model) { _, newModel in
|
||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
||||
effort = levels.last ?? MobileEfforts.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ struct SessionDetailView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if store.canControl, let summary { controlBar(summary) }
|
||||
|
||||
Divider()
|
||||
|
||||
if tab == 0 {
|
||||
@@ -58,6 +60,67 @@ struct SessionDetailView: View {
|
||||
.onDisappear { store.closeOpen() }
|
||||
}
|
||||
|
||||
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
|
||||
private var controlled: Bool {
|
||||
store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false
|
||||
}
|
||||
|
||||
private var modelBinding: Binding<String?> {
|
||||
Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
|
||||
}
|
||||
private var effortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
let fallback = store.modelCatalog.fallbackEffort.isEmpty
|
||||
? MobileEfforts.fallback : store.modelCatalog.fallbackEffort
|
||||
return summary?.effort ?? fallback
|
||||
},
|
||||
set: { store.setSessionEffort(sessionID, $0) })
|
||||
}
|
||||
|
||||
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
|
||||
/// from the transcript exactly as the Mac header does.
|
||||
private var contextPercent: Int? {
|
||||
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in
|
||||
switch event.kind {
|
||||
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
|
||||
case .usage(let usage): return usage.contextInputTokens
|
||||
default: return nil
|
||||
}
|
||||
}.first { $0 > 0 }
|
||||
guard let used else { return nil }
|
||||
let window = store.modelCatalog.contextWindow(summary?.model)
|
||||
guard window > 0 else { return nil }
|
||||
return min(100, Int((Double(used) / Double(window)) * 100))
|
||||
}
|
||||
|
||||
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
|
||||
/// session header. Reads current state from the summary; each change is a control intent.
|
||||
@ViewBuilder
|
||||
private func controlBar(_ summary: WireSessionSummary) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
||||
// No explicit model yet → use the session backend's default, so the menu
|
||||
// shows that backend's effort range rather than collapsing to one level.
|
||||
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
|
||||
controlled: controlled)
|
||||
Spacer(minLength: 4)
|
||||
if let percent = contextPercent {
|
||||
Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.help("Context window used")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) {
|
||||
Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash")
|
||||
}
|
||||
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionMenu(_ summary: WireSessionSummary) -> some View {
|
||||
Menu {
|
||||
@@ -68,6 +131,14 @@ struct SessionDetailView: View {
|
||||
Label(summary.favorite ? "Unfavorite" : "Favorite",
|
||||
systemImage: summary.favorite ? "star.slash" : "star")
|
||||
}
|
||||
// Autoship is a Nucleic Control capability — offered only for Control projects, and
|
||||
// the host couples it with auto-approval (enabling it turns Auto on).
|
||||
if controlled {
|
||||
Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
|
||||
Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
|
||||
systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
|
||||
}
|
||||
}
|
||||
if summary.status == .running {
|
||||
Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") }
|
||||
}
|
||||
@@ -140,19 +211,27 @@ struct SessionDetailView: View {
|
||||
|
||||
struct TranscriptList: View {
|
||||
let events: [AgentEvent]
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||
|
||||
private var items: [TranscriptItem] {
|
||||
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(events, id: \.seq) { event in
|
||||
TranscriptRow(event: event).id(event.seq)
|
||||
ForEach(items) { item in
|
||||
TranscriptRow(item: item).id(item.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
||||
// so every streamed delta keeps the view pinned to the bottom.
|
||||
.onChange(of: events.count) {
|
||||
if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
|
||||
if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -22,6 +24,15 @@ struct SettingsView: View {
|
||||
.font(.footnote.monospaced())
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show lock events", isOn: $showLockEvents)
|
||||
Toggle("Show raw events", isOn: $showRaw)
|
||||
} header: {
|
||||
Text("Transcript")
|
||||
} footer: {
|
||||
Text("Raw events are unrecognized backend output, shown for debugging.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showScanner = true
|
||||
|
||||
@@ -137,31 +137,110 @@ enum MobileEfforts {
|
||||
static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort }
|
||||
}
|
||||
|
||||
/// A compact effort selector for the remote composers. Orchestra sits below the API levels,
|
||||
/// set apart with a sparkles glyph and the purple accent. Orchestra is a Nucleic Control
|
||||
/// capability, so it's only selectable when the target project is under Control — disabled and
|
||||
/// labeled otherwise.
|
||||
extension WireModelCatalog {
|
||||
/// Effort levels to offer for `sku`. When no model is selected yet, fall back to the default
|
||||
/// model's range (not the lone fallback level), and to the built-in list before a `Welcome`
|
||||
/// catalog has arrived (an empty catalog, e.g. demo / older host).
|
||||
func offeredEfforts(forModel sku: String?) -> [String] {
|
||||
guard !groups.isEmpty else { return MobileEfforts.levels }
|
||||
let levels = efforts(forModel: sku ?? fallbackModel)
|
||||
return levels.isEmpty ? MobileEfforts.levels : levels
|
||||
}
|
||||
/// The orchestra sentinel, or the built-in default when no catalog is present.
|
||||
var orchestraSentinelOrFallback: String {
|
||||
orchestraSentinel.isEmpty ? MobileEfforts.orchestraSentinel : orchestraSentinel
|
||||
}
|
||||
/// Pretty label for an effort level, tolerating the empty (pre-`Welcome`) catalog.
|
||||
func offeredEffortDisplayName(_ effort: String) -> String {
|
||||
groups.isEmpty ? MobileEfforts.displayName(effort) : effortDisplayName(effort)
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` — the
|
||||
/// home composer, where the model also selects the backend — shows every provider group with
|
||||
/// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that
|
||||
/// provider's models. Selecting sets the SKU; the binding's `nil` means "host default".
|
||||
struct ModelMenu: View {
|
||||
@Binding var model: String?
|
||||
let catalog: WireModelCatalog
|
||||
var backend: BackendID? = nil
|
||||
|
||||
private var groups: [[WireModelCatalog.Model]] {
|
||||
if let backend {
|
||||
let models = catalog.models(for: backend)
|
||||
return models.isEmpty ? [] : [models]
|
||||
}
|
||||
return catalog.groups
|
||||
}
|
||||
private var label: String { model.map { catalog.displayName($0) } ?? "Default model" }
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(Array(groups.enumerated()), id: \.offset) { index, group in
|
||||
if index > 0 { Divider() }
|
||||
ForEach(group) { item in
|
||||
Button { model = item.sku } label: { menuLabel(item) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "cpu")
|
||||
Text(label)
|
||||
if let badge = model.flatMap({ catalog.contextBadge($0) }) {
|
||||
Text(badge).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
.disabled(groups.isEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private func menuLabel(_ item: WireModelCatalog.Model) -> some View {
|
||||
let name = item.contextBadge.map { "\(item.displayName) · \($0)" } ?? item.displayName
|
||||
if item.sku == model { Label(name, systemImage: "checkmark") } else { Text(name) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact effort selector for the remote composers, driven by the wire catalog so it offers
|
||||
/// exactly the levels the chosen model supports (Codex caps at xhigh, Grok exposes only Auto).
|
||||
/// Orchestra sits below the API levels with a sparkles glyph and gold accent; it's a Nucleic
|
||||
/// Control capability, so it's only selectable when the target project is under Control.
|
||||
struct EffortMenu: View {
|
||||
@Binding var effort: String
|
||||
let catalog: WireModelCatalog
|
||||
/// The model whose effort caps apply (nil → fall back to the built-in level list).
|
||||
var modelSKU: String? = nil
|
||||
/// Whether the target project is under Nucleic Control (gates Orchestra).
|
||||
var controlled: Bool = false
|
||||
|
||||
private var levels: [String] { catalog.offeredEfforts(forModel: modelSKU) }
|
||||
private var sentinel: String { catalog.orchestraSentinelOrFallback }
|
||||
private func isOrchestra(_ e: String) -> Bool { e.caseInsensitiveCompare(sentinel) == .orderedSame }
|
||||
private func display(_ e: String) -> String {
|
||||
isOrchestra(e) ? "Orchestra" : catalog.offeredEffortDisplayName(e)
|
||||
}
|
||||
|
||||
/// Orchestra only counts as active where it's permitted; a stray selection on a non-Control
|
||||
/// project reads as its underlying level so the trigger never shows Orchestra where it can't run.
|
||||
private var orchestraActive: Bool { controlled && MobileEfforts.isOrchestra(effort) }
|
||||
/// project (or a level the current model doesn't support) reads as the nearest supported level
|
||||
/// so the trigger never shows something that can't run.
|
||||
private var orchestraActive: Bool { controlled && isOrchestra(effort) }
|
||||
private var triggerEffort: String {
|
||||
orchestraActive ? effort : (MobileEfforts.isOrchestra(effort) ? MobileEfforts.fallback : effort)
|
||||
if orchestraActive { return effort }
|
||||
if isOrchestra(effort) { return levels.last ?? MobileEfforts.fallback }
|
||||
return levels.contains(effort) ? effort : (levels.last ?? MobileEfforts.fallback)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(MobileEfforts.levels, id: \.self) { level in
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button { effort = level } label: {
|
||||
if effort == level { Label(level, systemImage: "checkmark") } else { Text(level) }
|
||||
if effort == level { Label(display(level), systemImage: "checkmark") }
|
||||
else { Text(display(level)) }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button { effort = MobileEfforts.orchestraSentinel } label: {
|
||||
Button { effort = sentinel } label: {
|
||||
Label(
|
||||
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
|
||||
systemImage: orchestraActive ? "checkmark" : "sparkles")
|
||||
@@ -170,7 +249,7 @@ struct EffortMenu: View {
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
|
||||
Text(MobileEfforts.displayName(triggerEffort))
|
||||
Text(display(triggerEffort))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. Collapsed it
|
||||
/// shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
|
||||
/// input, the result, and any files the call touched. The old remote rendered `started` and
|
||||
/// `completed` as two separate one-line rows and dropped results/file-changes entirely.
|
||||
struct ToolGroupRow: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
|
||||
if expanded { details }
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(
|
||||
group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ToolGlyph.icon(group.name))
|
||||
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
||||
Text(group.name).font(.caption.weight(.semibold))
|
||||
Text(group.input.compactSummary)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder private var details: some View {
|
||||
let input = group.input.approvalDetail
|
||||
if !input.isEmpty {
|
||||
ToolBlock(label: "Input", text: input, mono: true)
|
||||
}
|
||||
if let result = group.result {
|
||||
ToolBlock(label: group.isError ? "Error" : "Result",
|
||||
text: result.compactSummary, mono: true, danger: group.isError)
|
||||
}
|
||||
if !group.fileChanges.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(group.fileChanges, id: \.path) { change in
|
||||
Label(change.path, systemImage: ToolGlyph.fileChange(change.change))
|
||||
.font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's
|
||||
/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool.
|
||||
struct OrchestrationCard: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var subtitle: String {
|
||||
group.input["description"]?.stringValue
|
||||
?? group.input["prompt"]?.stringValue
|
||||
?? group.input.compactSummary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(Palette.orchestra)
|
||||
Text(group.finished ? "Orchestrated" : "Orchestrating")
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if expanded {
|
||||
ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false)
|
||||
if let result = group.result {
|
||||
ToolBlock(label: "Result", text: result.compactSummary, mono: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A labeled, scroll-capped block of monospaced (or prose) text for a tool's input/result.
|
||||
private struct ToolBlock: View {
|
||||
let label: String
|
||||
let text: String
|
||||
var mono: Bool = true
|
||||
var danger: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
Text(text)
|
||||
.font(mono ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(danger ? Palette.danger : .secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbols for the common tools + file-change kinds, so a glance reads the action.
|
||||
enum ToolGlyph {
|
||||
static func icon(_ name: String) -> String {
|
||||
switch name {
|
||||
case "Bash", "Shell": return "terminal"
|
||||
case "Read": return "doc.text"
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
|
||||
case "Grep", "Glob", "Search": return "magnifyingglass"
|
||||
case "WebFetch", "WebSearch": return "globe"
|
||||
case "Task", "Agent": return "sparkles"
|
||||
case "TodoWrite": return "checklist"
|
||||
case "AskUserQuestion": return "questionmark.bubble"
|
||||
default: return "wrench.and.screwdriver"
|
||||
}
|
||||
}
|
||||
static func fileChange(_ kind: FileChange.ChangeKind) -> String {
|
||||
switch kind {
|
||||
case .add: return "plus.circle"
|
||||
case .update: return "pencil.circle"
|
||||
case .delete: return "minus.circle"
|
||||
case .rename: return "arrow.triangle.branch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
|
||||
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
|
||||
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
|
||||
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
|
||||
/// `TranscriptProjection` at phone fidelity.)
|
||||
struct TranscriptItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
/// The seq of the first event that created this item — stable scroll anchor + ordering.
|
||||
let seq: UInt64
|
||||
var kind: Kind
|
||||
|
||||
enum Role: Equatable { case user, assistant }
|
||||
|
||||
enum Kind: Equatable {
|
||||
case message(role: Role, text: String)
|
||||
case thinking(text: String)
|
||||
case tool(ToolGroup)
|
||||
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
|
||||
case orchestration(ToolGroup)
|
||||
case sessionStarted(model: String, cwd: String)
|
||||
case usage(Usage)
|
||||
case rateLimit(RateLimit)
|
||||
case turnBoundary
|
||||
case approval(toolName: String)
|
||||
case runFinished(outcome: RunFinished.Outcome)
|
||||
case error(message: String)
|
||||
case note(text: String, icon: String?, lockEvent: Bool)
|
||||
case raw(type: String, body: String)
|
||||
}
|
||||
}
|
||||
|
||||
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
||||
struct ToolGroup: Equatable {
|
||||
var toolCallID: String
|
||||
var name: String
|
||||
var input: JSONValue
|
||||
var result: JSONValue?
|
||||
var isError: Bool = false
|
||||
var finished: Bool = false
|
||||
/// Paths the call touched (from `fileChange` events tagged with this tool call).
|
||||
var fileChanges: [FilePatch] = []
|
||||
|
||||
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
|
||||
|
||||
/// Subagent orchestration (`Task`/`Agent`) gets the gold card treatment, like the Mac.
|
||||
var isOrchestration: Bool { name == "Task" || name == "Agent" }
|
||||
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
|
||||
}
|
||||
|
||||
enum TranscriptProjection {
|
||||
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
|
||||
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
|
||||
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
||||
var items: [TranscriptItem] = []
|
||||
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
|
||||
var thinkingIndex: [String: Int] = [:]
|
||||
var toolIndex: [String: Int] = [:] // toolCallID → items index
|
||||
|
||||
for event in events {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
||||
case .assistantText(let chunk):
|
||||
coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
||||
case .thinking(let chunk):
|
||||
coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex)
|
||||
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }()
|
||||
upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex)
|
||||
case .toolCallInputDelta:
|
||||
break // the final `toolCallCompleted` carries the assembled input
|
||||
case .toolResult(let result):
|
||||
attachResult(result, into: &items, index: toolIndex)
|
||||
case .fileChange(let change):
|
||||
attachFileChange(change, seq: event.seq, into: &items, index: toolIndex)
|
||||
|
||||
case .sessionStarted(let started):
|
||||
items.append(.init(id: "start-\(event.seq)", seq: event.seq,
|
||||
kind: .sessionStarted(model: started.model, cwd: started.cwd)))
|
||||
case .usage(let usage):
|
||||
items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
|
||||
case .rateLimit(let limit):
|
||||
items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
|
||||
case .turnCompleted:
|
||||
items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
|
||||
case .approvalRequested(let req):
|
||||
items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName)))
|
||||
case .approvalResolved:
|
||||
break // dismissal is reflected in the live approval card, not the transcript
|
||||
case .runFinished(let finished):
|
||||
items.append(.init(id: "fin-\(event.seq)", seq: event.seq,
|
||||
kind: .runFinished(outcome: finished.outcome)))
|
||||
case .error(let err):
|
||||
items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message)))
|
||||
case .note(let note):
|
||||
if note.lockEvent && !showLockEvents { break }
|
||||
items.append(.init(id: "note-\(event.seq)", seq: event.seq,
|
||||
kind: .note(text: note.text, icon: note.icon, lockEvent: note.lockEvent)))
|
||||
case .raw(let raw):
|
||||
guard showRaw else { break }
|
||||
items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
|
||||
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Coalescing helpers
|
||||
|
||||
private static func coalesceText(
|
||||
_ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64,
|
||||
into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind {
|
||||
// A non-partial chunk is the authoritative full text; partials accumulate.
|
||||
let text = chunk.isPartial ? existing + chunk.text : chunk.text
|
||||
items[i].kind = .message(role: r, text: text)
|
||||
} else {
|
||||
index[chunk.messageID] = items.count
|
||||
items.append(.init(id: "msg-\(chunk.messageID)", seq: seq,
|
||||
kind: .message(role: role, text: chunk.text)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func coalesceThinking(
|
||||
_ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind {
|
||||
items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text)
|
||||
} else {
|
||||
index[chunk.messageID] = items.count
|
||||
items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func upsertTool(
|
||||
_ call: ToolCall, finished: Bool, seq: UInt64,
|
||||
into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[call.toolCallID], var group = currentGroup(items[i]) {
|
||||
group.name = call.name
|
||||
if !call.input.isEmptyValue { group.input = call.input }
|
||||
group.finished = group.finished || finished
|
||||
items[i].kind = wrap(group)
|
||||
} else {
|
||||
index[call.toolCallID] = items.count
|
||||
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func attachResult(
|
||||
_ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int]
|
||||
) {
|
||||
guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return }
|
||||
group.result = result.content
|
||||
group.isError = result.isError
|
||||
group.finished = true
|
||||
items[i].kind = wrap(group)
|
||||
}
|
||||
|
||||
private static func attachFileChange(
|
||||
_ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int]
|
||||
) {
|
||||
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
|
||||
group.fileChanges.append(.init(path: change.path, change: change.kind))
|
||||
items[i].kind = wrap(group)
|
||||
}
|
||||
// Untagged file changes are folded into the diff stat, not the transcript.
|
||||
}
|
||||
|
||||
/// Extract a tool group from either the plain or orchestration kind.
|
||||
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
|
||||
switch item.kind {
|
||||
case .tool(let g), .orchestration(let g): return g
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a group in the right kind — orchestration spawns get the gold card.
|
||||
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
|
||||
group.isOrchestration ? .orchestration(group) : .tool(group)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// Whether this value carries nothing worth keeping (so a later, fuller input wins).
|
||||
var isEmptyValue: Bool {
|
||||
switch self {
|
||||
case .null: return true
|
||||
case .object(let o): return o.isEmpty
|
||||
case .string(let s): return s.isEmpty
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,174 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as
|
||||
/// bubbles; tool calls as collapsible rows; everything else compact.
|
||||
/// Read-friendly rendering of one projected transcript row (UX_IOS §4) — the mobile echo of the
|
||||
/// Mac transcript. Assistant/user prose as Markdown bubbles, tool calls as collapsible cards,
|
||||
/// Orchestra spawns in gold, plus the informational rows (usage/cost, rate limits, turn
|
||||
/// boundaries, run outcome) the older remote dropped.
|
||||
struct TranscriptRow: View {
|
||||
let event: AgentEvent
|
||||
@State private var expanded = false
|
||||
let item: TranscriptItem
|
||||
|
||||
var body: some View {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
bubble(chunk.text, role: .user)
|
||||
case .assistantText(let chunk):
|
||||
bubble(chunk.text, role: .assistant)
|
||||
case .thinking(let chunk):
|
||||
Text(chunk.text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
toolRow(name: call.name, detail: call.input.compactSummary)
|
||||
case .toolResult(let result):
|
||||
DisclosureGroup("Result") {
|
||||
Text(result.content.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
case .approvalRequested(let req):
|
||||
Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
case .runFinished(let finished):
|
||||
Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered")
|
||||
switch item.kind {
|
||||
case .message(let role, let text):
|
||||
MessageBubble(role: role, text: text)
|
||||
case .thinking(let text):
|
||||
ThinkingRow(text: text)
|
||||
case .tool(let group):
|
||||
ToolGroupRow(group: group)
|
||||
case .orchestration(let group):
|
||||
OrchestrationCard(group: group)
|
||||
case .sessionStarted(let model, let cwd):
|
||||
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
|
||||
case .usage(let usage):
|
||||
UsageRow(usage: usage)
|
||||
case .rateLimit(let limit):
|
||||
RateLimitRow(limit: limit)
|
||||
case .turnBoundary:
|
||||
TurnBoundaryRow()
|
||||
case .approval(let toolName):
|
||||
Label("Approval requested: \(toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(Palette.attention)
|
||||
case .runFinished(let outcome):
|
||||
RunFinishedRow(outcome: outcome)
|
||||
case .error(let message):
|
||||
Label(message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(Palette.danger)
|
||||
case .note(let text, let icon, _):
|
||||
Label(text, systemImage: icon ?? "arrow.triangle.branch")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
case .error(let err):
|
||||
Label(err.message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(.red)
|
||||
case .note(let note):
|
||||
Label(note.text, systemImage: note.icon ?? "arrow.triangle.branch")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
default:
|
||||
EmptyView()
|
||||
case .raw(let type, let body):
|
||||
Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Role { case user, assistant }
|
||||
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bubble(_ text: String, role: Role) -> some View {
|
||||
/// Render an assistant/user message as Markdown (inline bold/italic/code/links), preserving the
|
||||
/// line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
||||
struct MessageBubble: View {
|
||||
let role: TranscriptItem.Role
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Text(text)
|
||||
Text(Self.markdown(text))
|
||||
.textSelection(.enabled)
|
||||
.padding(10)
|
||||
.background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
||||
if role == .assistant { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
|
||||
private func toolRow(name: String, detail: String) -> some View {
|
||||
static func markdown(_ string: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: string, options: .init(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
||||
failurePolicy: .returnPartiallyParsedIfPossible))) ?? AttributedString(string)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThinkingRow: View {
|
||||
let text: String
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted single-line note with an optional trailing detail (e.g. session-started cwd).
|
||||
struct InfoLine: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
var detail: String? = nil
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
if let detail { Text(detail).lineLimit(1).truncationMode(.middle).foregroundStyle(.tertiary) }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-turn token accounting + cost — the signal the desktop shows and the old remote dropped.
|
||||
struct UsageRow: View {
|
||||
let usage: Usage
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let input = usage.inputTokens { metric("arrow.down", tokens(input)) }
|
||||
if let output = usage.outputTokens { metric("arrow.up", tokens(output)) }
|
||||
if let cost = usage.costUSD, cost > 0 {
|
||||
Text(String(format: "$%.4f", cost)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
private func metric(_ icon: String, _ value: String) -> some View {
|
||||
HStack(spacing: 2) { Image(systemName: icon); Text(value) }
|
||||
}
|
||||
private func tokens(_ n: Int) -> String {
|
||||
n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate-limit warning so the user sees a window tightening before a turn fails — first-class,
|
||||
/// not buried as a raw event.
|
||||
struct RateLimitRow: View {
|
||||
let limit: RateLimit
|
||||
private var warn: Bool { (limit.status ?? "").contains("warning") || limit.status == "rejected" }
|
||||
var body: some View {
|
||||
Label(text, systemImage: warn ? "exclamationmark.circle.fill" : "clock.arrow.circlepath")
|
||||
.font(.caption).foregroundStyle(warn ? Palette.attention : .secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
private var text: String {
|
||||
var parts = ["Rate limit"]
|
||||
if let type = limit.rateLimitType { parts.append(type.replacingOccurrences(of: "_", with: " ")) }
|
||||
if let status = limit.status { parts.append(status.replacingOccurrences(of: "_", with: " ")) }
|
||||
if let resets = limit.resetsAt {
|
||||
parts.append("· resets \(resets.formatted(date: .omitted, time: .shortened))")
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
struct TurnBoundaryRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary)
|
||||
Text(name).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
||||
Text("turn").font(.caption2).foregroundStyle(.tertiary)
|
||||
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RunFinishedRow: View {
|
||||
let outcome: RunFinished.Outcome
|
||||
var body: some View {
|
||||
Label(label, systemImage: icon).font(.caption).foregroundStyle(color)
|
||||
}
|
||||
private var label: String { "Run \(outcome.rawValue)" }
|
||||
private var icon: String {
|
||||
switch outcome {
|
||||
case .completed: "flag.checkered"
|
||||
case .interrupted: "stop.circle"
|
||||
case .errored: "xmark.octagon"
|
||||
case .maxTurns: "flag.checkered"
|
||||
}
|
||||
}
|
||||
private var color: Color {
|
||||
switch outcome {
|
||||
case .completed, .maxTurns: Palette.success
|
||||
case .interrupted: Palette.paused
|
||||
case .errored: Palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user