@lotics/ui 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
7
+ import { SegmentedControl } from "@lotics/ui/segmented_control";
8
+ import { EmptyState } from "@lotics/ui/empty_state";
9
+ import { MatchRow } from "@lotics/ui/match_row";
10
+ import type { ConfidenceLevel } from "@lotics/ui/confidence";
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Template · Match & reconcile — the agent proposes PAIRINGS and shows its
14
+ // reasoning; the human confirms, reassigns, or rejects. Each row is two-sided
15
+ // (a known item ↔ the agent's proposed counterpart) with a confidence on the
16
+ // bridge. High-confidence pairs batch-accept; the rest are worked one by one.
17
+ // Same shape for bank-line ↔ invoice, invoice ↔ PO, dedup A ↔ B, entity
18
+ // resolution. The deterministic cousin (tpl_reconcile) has no agent reasoning.
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ interface M {
22
+ id: string;
23
+ amount: number;
24
+ source: { title: string; detail: string };
25
+ match?: { title: string; detail: string };
26
+ /** An alternate candidate the agent considered — drives Reassign. */
27
+ alt?: { title: string; detail: string };
28
+ rationale: string;
29
+ confidence: ConfidenceLevel;
30
+ status: "open" | "accepted" | "dismissed";
31
+ }
32
+
33
+ const SEED: M[] = [
34
+ { id: "m1", amount: 86_200_000, source: { title: "INCOMING TT — ATLAS COMPONENTS", detail: "09 Jun · 86,200,000 ₫" }, match: { title: "INV-2026-0312", detail: "Atlas Components · 86,200,000 ₫" }, rationale: "Exact amount and the invoice number appears in the transfer memo.", confidence: "high", status: "open" },
35
+ { id: "m2", amount: 41_300_000, source: { title: "CRESTLINE TT PAYMENT", detail: "09 Jun · 41,300,000 ₫" }, match: { title: "INV-2026-0318", detail: "Crestline Furniture · 41,300,000 ₫" }, rationale: "Amount matches to the đồng and the payer name maps to the customer on file.", confidence: "high", status: "open" },
36
+ { id: "m3", amount: 22_500_000, source: { title: "VITTORIA ACC — REF 0321", detail: "10 Jun · 22,500,000 ₫" }, match: { title: "INV-2026-0321", detail: "Vittoria Accessories · 22,500,000 ₫" }, rationale: "Reference 0321 cited; amount exact.", confidence: "high", status: "open" },
37
+ { id: "m4", amount: 18_700_000, source: { title: "BRIGHTCELL JUNE", detail: "10 Jun · 18,700,000 ₫" }, match: { title: "INV-2026-0324", detail: "Brightcell Batteries · 18,700,000 ₫" }, alt: { title: "INV-2026-0319", detail: "Brightcell Batteries · 18,700,000 ₫" }, rationale: "Amount + customer match, but two open invoices share this total — confirm the period.", confidence: "medium", status: "open" },
38
+ { id: "m5", amount: 12_600_000, source: { title: "MERIDIAN PART PAYMENT", detail: "12 Jun · 12,600,000 ₫" }, match: { title: "INV-2026-0326", detail: "Meridian Construction · 25,200,000 ₫" }, rationale: "Half the invoice total from the same payer — likely a partial payment.", confidence: "medium", status: "open" },
39
+ { id: "m6", amount: 25_200_000, source: { title: "INCOMING TT REF 88412", detail: "11 Jun · 25,200,000 ₫" }, alt: { title: "INV-2026-0326", detail: "Meridian Construction · 25,200,000 ₫" }, rationale: "No name or reference on the line; the amount matches one open invoice but the agent isn't confident.", confidence: "low", status: "open" },
40
+ { id: "m7", amount: 264_000, source: { title: "BANK CHARGES JUNE", detail: "11 Jun · 264,000 ₫" }, rationale: "No invoice fits; the description reads as a bank fee, not a receipt.", confidence: "low", status: "open" },
41
+ { id: "m8", amount: 64_800_000, source: { title: "ATLAS COMPONENTS — INV-0301", detail: "08 Jun · 64,800,000 ₫" }, match: { title: "INV-2026-0301", detail: "Atlas Components · 64,800,000 ₫" }, rationale: "Exact reference and amount.", confidence: "high", status: "accepted" },
42
+ ];
43
+
44
+ export function TplMatch() {
45
+ const [rows, setRows] = useState<M[]>(SEED);
46
+ const [tab, setTab] = useState<"open" | "resolved">("open");
47
+
48
+ const set = (id: string, status: M["status"]) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
49
+ const reassign = (id: string) =>
50
+ setRows((prev) =>
51
+ prev.map((r) =>
52
+ r.id === id && r.alt ? { ...r, match: r.alt, alt: r.match, confidence: "medium", rationale: "Reassigned to the alternate candidate the agent considered." } : r,
53
+ ),
54
+ );
55
+ const acceptAllHigh = () => setRows((prev) => prev.map((r) => (r.status === "open" && r.confidence === "high" && r.match ? { ...r, status: "accepted" } : r)));
56
+
57
+ const open = rows.filter((r) => r.status === "open");
58
+ const resolved = rows.filter((r) => r.status !== "open");
59
+ const visible = tab === "open" ? open : resolved;
60
+ const accepted = rows.filter((r) => r.status === "accepted");
61
+ const matchedValue = accepted.reduce((s, r) => s + r.amount, 0);
62
+ const unmatched = open.filter((r) => !r.match).length;
63
+ const highOpen = open.filter((r) => r.confidence === "high" && r.match).length;
64
+
65
+ return (
66
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
67
+ <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
68
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
69
+ <View style={{ flex: 1, gap: 2 }}>
70
+ <Text size="xl" weight="semibold">Payment matching</Text>
71
+ <Text size="sm" color="muted">The agent pairs incoming payments to open invoices — you confirm, reassign, or reject</Text>
72
+ </View>
73
+ {tab === "open" && highOpen > 0 ? (
74
+ <Button title={`Accept ${highOpen} high-confidence`} color="secondary" shape="rounded" onPress={acceptAllHigh} />
75
+ ) : null}
76
+ </View>
77
+
78
+ <KPIStrip
79
+ items={[
80
+ { label: "Proposed", value: open.length, format: "number", caption: "awaiting a decision" },
81
+ { label: "Accepted", value: accepted.length, format: "number" },
82
+ { label: "Unmatched", value: unmatched, format: "number", tone: unmatched > 0 ? "warning" : "default", info: "Lines the agent couldn't pair confidently — they need a human to find the counterpart or take an escape hatch." },
83
+ { label: "Matched value", value: matchedValue, format: "currency", compact: true, info: "Money tied to an invoice through an accepted pairing." },
84
+ ]}
85
+ />
86
+
87
+ <SegmentedControl
88
+ accessibilityLabel="Queue"
89
+ options={[
90
+ { label: `Proposed · ${open.length}`, value: "open" },
91
+ { label: `Resolved · ${resolved.length}`, value: "resolved" },
92
+ ]}
93
+ value={tab}
94
+ onValueChange={setTab}
95
+ />
96
+
97
+ {visible.length === 0 ? (
98
+ <EmptyState
99
+ icon="circle-check"
100
+ message={tab === "open" ? "Queue cleared" : "Nothing resolved yet"}
101
+ hint={tab === "open" ? "Every proposal has been accepted or rejected" : undefined}
102
+ />
103
+ ) : (
104
+ <View style={{ gap: 10 }}>
105
+ {visible.map((r) => (
106
+ <MatchRow
107
+ key={r.id}
108
+ source={r.source}
109
+ match={r.match}
110
+ rationale={r.rationale}
111
+ confidence={r.confidence}
112
+ status={r.status}
113
+ onAccept={r.match ? () => set(r.id, "accepted") : undefined}
114
+ onReassign={r.alt ? () => reassign(r.id) : undefined}
115
+ onDismiss={() => set(r.id, "dismissed")}
116
+ />
117
+ ))}
118
+ </View>
119
+ )}
120
+ </View>
121
+ </ScrollView>
122
+ );
123
+ }
@@ -0,0 +1,112 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
7
+ import { SegmentedControl } from "@lotics/ui/segmented_control";
8
+ import { EmptyState } from "@lotics/ui/empty_state";
9
+ import { TriageRow } from "@lotics/ui/triage_row";
10
+ import type { ConfidenceLevel } from "@lotics/ui/confidence";
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Template · Triage — an inbox the agent has CLASSIFIED and routed. Each item
14
+ // carries the agent's category and a suggested action with a confidence; the
15
+ // human accepts the call, overrides it, or dismisses the item. High-confidence
16
+ // items batch-accept so attention goes to the ambiguous ones. Leads, support
17
+ // tickets, inbound documents, emails. The agent does the sorting; the human
18
+ // keeps the decision.
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ interface T {
22
+ id: string;
23
+ title: string;
24
+ preview: string;
25
+ meta: string;
26
+ category: { label: string };
27
+ suggestedAction: string;
28
+ confidence: ConfidenceLevel;
29
+ status: "open" | "accepted" | "dismissed";
30
+ }
31
+
32
+ const SEED: T[] = [
33
+ { id: "t1", title: "RFQ — 2×40HC Cát Lái → Hamburg, reefer", preview: "Hi, we need a quote for two reefer containers of frozen shrimp departing next week…", meta: "08:12", category: { label: "Sales" }, suggestedAction: "Assign to Nguyen", confidence: "high", status: "open" },
34
+ { id: "t2", title: "Where is my shipment HBL-4471?", preview: "Customer asking for an ETA update on the Hamburg booking, getting anxious about demurrage…", meta: "08:39", category: { label: "Support" }, suggestedAction: "Send ETA, open ticket", confidence: "high", status: "open" },
35
+ { id: "t3", title: "Invoice INV-2026-0318 — payment confirmation", preview: "Attached the TT receipt for 41,300,000 ₫, please confirm and release the documents…", meta: "09:02", category: { label: "Billing" }, suggestedAction: "Apply the payment", confidence: "high", status: "open" },
36
+ { id: "t4", title: "Re: partnership opportunity (sponsored)", preview: "Boost your logistics with our revolutionary platform, limited-time offer just for you…", meta: "09:15", category: { label: "Spam" }, suggestedAction: "Move to spam", confidence: "high", status: "open" },
37
+ { id: "t5", title: "Complaint — damaged cartons on delivery", preview: "Three cartons arrived crushed, we need a claim opened and a replacement schedule…", meta: "09:41", category: { label: "Support" }, suggestedAction: "Open a damage claim", confidence: "medium", status: "open" },
38
+ { id: "t6", title: "Quote request — air freight, urgent samples", preview: "Need 12 kg of samples to Rotterdam by Friday, what's the fastest option and cost…", meta: "10:03", category: { label: "Sales" }, suggestedAction: "Assign to the air desk", confidence: "medium", status: "open" },
39
+ { id: "t7", title: "Updated bank details for remittance", preview: "Please update our account for future payments to the following beneficiary…", meta: "10:20", category: { label: "Billing" }, suggestedAction: "Hold — verify sender first", confidence: "low", status: "open" },
40
+ { id: "t8", title: "Newsletter: port congestion outlook Q3", preview: "Our latest market report on Asia–Europe capacity and rate trends is now available…", meta: "Yesterday", category: { label: "Spam" }, suggestedAction: "Move to spam", confidence: "medium", status: "accepted" },
41
+ ];
42
+
43
+ export function TplTriage() {
44
+ const [rows, setRows] = useState<T[]>(SEED);
45
+ const [tab, setTab] = useState<"open" | "resolved">("open");
46
+
47
+ const set = (id: string, status: T["status"]) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
48
+ const acceptAllHigh = () => setRows((prev) => prev.map((r) => (r.status === "open" && r.confidence === "high" ? { ...r, status: "accepted" } : r)));
49
+
50
+ const open = rows.filter((r) => r.status === "open");
51
+ const resolved = rows.filter((r) => r.status !== "open");
52
+ const visible = tab === "open" ? open : resolved;
53
+ const highOpen = open.filter((r) => r.confidence === "high").length;
54
+ const review = open.filter((r) => r.confidence !== "high").length;
55
+
56
+ return (
57
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
58
+ <View style={{ width: "100%", maxWidth: 820, alignSelf: "center", gap: 16 }}>
59
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
60
+ <View style={{ flex: 1, gap: 2 }}>
61
+ <Text size="xl" weight="semibold">Inbox triage</Text>
62
+ <Text size="sm" color="muted">The agent classifies and routes each item — accept the call, override, or dismiss</Text>
63
+ </View>
64
+ {tab === "open" && highOpen > 0 ? (
65
+ <Button title={`Accept ${highOpen} high-confidence`} color="secondary" shape="rounded" onPress={acceptAllHigh} />
66
+ ) : null}
67
+ </View>
68
+
69
+ <KPIStrip
70
+ items={[
71
+ { label: "New", value: open.length, format: "number", caption: "awaiting triage" },
72
+ { label: "Ready to auto-route", value: highOpen, format: "number", info: "High-confidence items the agent is sure about — safe to accept in bulk." },
73
+ { label: "Needs review", value: review, format: "number", tone: review > 0 ? "warning" : "default", info: "Medium / low confidence — the agent flagged these for a human to confirm the routing." },
74
+ { label: "Handled", value: rows.filter((r) => r.status === "accepted").length, format: "number" },
75
+ ]}
76
+ />
77
+
78
+ <SegmentedControl
79
+ accessibilityLabel="Queue"
80
+ options={[
81
+ { label: `New · ${open.length}`, value: "open" },
82
+ { label: `Handled · ${resolved.length}`, value: "resolved" },
83
+ ]}
84
+ value={tab}
85
+ onValueChange={setTab}
86
+ />
87
+
88
+ {visible.length === 0 ? (
89
+ <EmptyState icon="circle-check" message={tab === "open" ? "Inbox zero" : "Nothing handled yet"} hint={tab === "open" ? "Every item has been routed or dismissed" : undefined} />
90
+ ) : (
91
+ <View style={{ gap: 10 }}>
92
+ {visible.map((r) => (
93
+ <TriageRow
94
+ key={r.id}
95
+ title={r.title}
96
+ preview={r.preview}
97
+ meta={r.meta}
98
+ category={r.category}
99
+ suggestedAction={r.suggestedAction}
100
+ confidence={r.confidence}
101
+ status={r.status}
102
+ onAccept={() => set(r.id, "accepted")}
103
+ onOverride={() => {}}
104
+ onDismiss={() => set(r.id, "dismissed")}
105
+ />
106
+ ))}
107
+ </View>
108
+ )}
109
+ </View>
110
+ </ScrollView>
111
+ );
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -63,10 +63,17 @@
63
63
  "./prompt_field": "./src/prompt_field.tsx",
64
64
  "./confidence": "./src/confidence.tsx",
65
65
  "./suggestion": "./src/suggestion.tsx",
66
- "./assisted_field": "./src/assisted_field.tsx",
67
66
  "./change_review": "./src/change_review.tsx",
68
67
  "./clarify": "./src/clarify.tsx",
68
+ "./choice_list": "./src/choice_list.tsx",
69
69
  "./sources": "./src/sources.tsx",
70
+ "./record_review": "./src/record_review.tsx",
71
+ "./spec_list": "./src/spec_list.tsx",
72
+ "./match_row": "./src/match_row.tsx",
73
+ "./finding": "./src/finding.tsx",
74
+ "./discrepancy": "./src/discrepancy.tsx",
75
+ "./triage_row": "./src/triage_row.tsx",
76
+ "./scored_option": "./src/scored_option.tsx",
70
77
  "./icon": "./src/icon.tsx",
71
78
  "./dynamic_icon": {
72
79
  "react-native": "./src/dynamic_icon.tsx",
@@ -1,8 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { ScrollView, StyleSheet, View } from "react-native";
3
- import { colors, solid } from "./colors";
3
+ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
- import { Icon } from "./icon";
6
5
  import { WaveAvatar } from "./wave_avatar";
7
6
  import { PressableHighlight } from "./pressable_highlight";
8
7
  import { AgentRun, type AgentRunStep } from "./agent_run";
@@ -17,11 +16,12 @@ export interface AgentProgressProps {
17
16
  }
18
17
 
19
18
  /**
20
- * The compact, FLOATING agent-progress indicator — a pill (animated `WaveAvatar`
21
- * + the current step's label) that EXPANDS on press to the full `AgentRun`
22
- * stream. It's the "working" state of a composer on a canvas app: the composer
23
- * morphs into this while the agent runs, and reveals again when it's done.
24
- * Press to peek at the streamed steps without leaving the canvas.
19
+ * The compact, FLOATING agent-progress indicator — a pill (an animated
20
+ * `WaveAvatar` + the current step's label) that EXPANDS on press to the full
21
+ * `AgentRun` stream. It's the "working" state of a composer on a canvas app: the
22
+ * composer (`PromptField`) MORPHS into this while the agent runs, and reveals
23
+ * again when it's done. The whole pill is pressable; collapsed it shows only the
24
+ * label, so the surface stays calm until you ask to see the steps.
25
25
  */
26
26
  export function AgentProgress(props: AgentProgressProps) {
27
27
  const { steps, state = "streaming", label, defaultExpanded } = props;
@@ -29,12 +29,13 @@ export function AgentProgress(props: AgentProgressProps) {
29
29
 
30
30
  const running = steps.filter((s) => s.status === "running").at(-1);
31
31
  const compact = label ?? running?.label ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
32
+ const streaming = state === "streaming";
32
33
 
33
34
  return (
34
35
  <View style={{ gap: 8 }}>
35
36
  {expanded ? (
36
37
  <View style={styles.panel}>
37
- <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 14 }}>
38
+ <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
38
39
  <AgentRun steps={steps} state={state} />
39
40
  </ScrollView>
40
41
  </View>
@@ -45,27 +46,38 @@ export function AgentProgress(props: AgentProgressProps) {
45
46
  style={styles.pill}
46
47
  accessibilityLabel={expanded ? "Hide the agent's steps" : "Show the agent's steps"}
47
48
  >
48
- <WaveAvatar animated size={24} color={solid("violet")} />
49
- <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
49
+ <WaveAvatar animated={streaming} size={32} color={colors.zinc[900]} />
50
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flex: 1 }}>
50
51
  {compact}
51
52
  </Text>
52
- <Icon name={expanded ? "chevron-down" : "chevron-up"} size={16} color={colors.zinc[400]} />
53
53
  </PressableHighlight>
54
54
  </View>
55
55
  );
56
56
  }
57
57
 
58
- // The floating surface — white, soft elevation, a hairline edge — so it reads
59
- // as lifted off the canvas (matches the composer it morphs from).
60
- const FLOAT = {
61
- backgroundColor: colors.white,
62
- borderWidth: 1,
63
- borderColor: colors.border,
64
- borderRadius: 16,
65
- boxShadow: "0 6px 24px rgba(24,24,27,0.10)",
66
- } as const;
67
-
68
58
  const styles = StyleSheet.create({
69
- panel: { ...FLOAT, overflow: "hidden" },
70
- pill: { ...FLOAT, flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 10 },
59
+ // Same pill geometry as PromptField (minHeight 52, radius 999, 1.5 border,
60
+ // ph 6/left-padded for the avatar) so the composer→progress swap reads as a
61
+ // morph, not a replacement.
62
+ pill: {
63
+ flexDirection: "row",
64
+ alignItems: "center",
65
+ gap: 10,
66
+ minHeight: 52,
67
+ backgroundColor: colors.white,
68
+ borderWidth: 1.5,
69
+ borderColor: colors.border,
70
+ borderRadius: 999,
71
+ paddingLeft: 10,
72
+ paddingRight: 20,
73
+ boxShadow: "0 4px 14px rgba(24,24,27,0.08)",
74
+ },
75
+ panel: {
76
+ backgroundColor: colors.white,
77
+ borderWidth: 1,
78
+ borderColor: colors.border,
79
+ borderRadius: 16,
80
+ overflow: "hidden",
81
+ boxShadow: "0 6px 24px rgba(24,24,27,0.10)",
82
+ },
71
83
  });
package/src/agent_run.tsx CHANGED
@@ -1,9 +1,9 @@
1
- import { useEffect, useRef } from "react";
2
- import { Animated, Easing, StyleSheet, View } from "react-native";
1
+ import { StyleSheet, View } from "react-native";
3
2
  import { colors, solid, tint } from "./colors";
4
3
  import { Text } from "./text";
5
- import { Icon } from "./icon";
6
- import { DotsIndicator } from "./dots_indicator";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { ActivityIndicator } from "./activity_indicator";
6
+ import { AnimationFadeIn } from "./animation_fade_in";
7
7
 
8
8
  export type AgentStepStatus = "running" | "done" | "error";
9
9
 
@@ -23,8 +23,8 @@ export interface AgentRunStep {
23
23
 
24
24
  export interface AgentRunProps {
25
25
  steps: AgentRunStep[];
26
- /** Whole-run state. `streaming` keeps the feed alive — the running node
27
- * pulses and the header shows a working indicator; `done`/`error` settle it.
26
+ /** Whole-run state. `streaming` keeps the feed alive — the running node spins
27
+ * and the header shows a working indicator; `done`/`error` settle it.
28
28
  * Defaults to `streaming` while any step is running, else `done`. */
29
29
  state?: "streaming" | "done" | "error";
30
30
  /** Header label — the task the agent is performing ("Designing the dieline").
@@ -35,13 +35,13 @@ export interface AgentRunProps {
35
35
 
36
36
  /**
37
37
  * A live feed of an AI agent's work — the steps it takes (reasoning, tool
38
- * calls, results) revealed as they happen, on a connecting spine. This is the
39
- * transparent-work surface: NOT a spinner. The running node pulses violet (the
40
- * AI accent) while the model streams; finished steps settle to an emerald
41
- * check, failures to a red alert. Pair with `PromptField` (the command that
42
- * starts a run) and `Suggestion` (the result to review). Unlike `StepList` (a
43
- * known, guided run of fixed steps) the steps here arrive unknown-ahead, as the
44
- * agent decides them.
38
+ * calls, results) revealed as they happen, on a connecting spine. The
39
+ * transparent-work surface, NOT a spinner. Shares the `Timeline` node anatomy: a
40
+ * tinted disc behind a full-colour icon, fading in as each step arrives. A
41
+ * running step spins (blue); a finished step settles to an emerald check; a
42
+ * failure to a red alert. Pair with `PromptField` (the command that starts a
43
+ * run) and `Suggestion` (the result to review). Unlike `StepList` (a known,
44
+ * guided run of fixed steps) the steps here arrive unknown-ahead.
45
45
  */
46
46
  export function AgentRun(props: AgentRunProps) {
47
47
  const { steps, title, accessibilityLabel } = props;
@@ -53,19 +53,10 @@ export function AgentRun(props: AgentRunProps) {
53
53
  <View accessibilityLabel={accessibilityLabel} style={{ gap: title ? 12 : 0 }}>
54
54
  {title ? (
55
55
  <View style={styles.header}>
56
- <View style={styles.glyph}>
57
- <Icon name="sparkles" size={13} color={solid("violet")} />
58
- </View>
59
56
  <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
60
57
  {title}
61
58
  </Text>
62
- {streaming ? (
63
- <DotsIndicator size={5} color={solid("violet")} />
64
- ) : (
65
- <Text size="xs" color={state === "error" ? "danger" : "muted"}>
66
- {state === "error" ? "Stopped" : "Done"}
67
- </Text>
68
- )}
59
+ <HeaderStatus state={state} streaming={streaming} />
69
60
  </View>
70
61
  ) : null}
71
62
 
@@ -75,13 +66,14 @@ export function AgentRun(props: AgentRunProps) {
75
66
  return (
76
67
  <View key={s.id} style={styles.item}>
77
68
  <View style={styles.spineCol}>
78
- <StepNode status={s.status} />
69
+ <AnimationFadeIn key={`${s.id}-${s.status}`}>
70
+ <StepNode status={s.status} />
71
+ </AnimationFadeIn>
79
72
  {!last ? <View style={styles.spine} /> : null}
80
73
  </View>
81
74
  <View style={[styles.contentCol, !last ? styles.contentGap : null]}>
82
75
  {s.kind === "tool" ? (
83
76
  <View style={styles.toolChip}>
84
- <Icon name="square-check" size={11} color={colors.zinc[500]} />
85
77
  <Text size="xs" color="muted" numberOfLines={1}>
86
78
  {s.label}
87
79
  </Text>
@@ -90,7 +82,7 @@ export function AgentRun(props: AgentRunProps) {
90
82
  <Text
91
83
  size="sm"
92
84
  weight={s.status === "running" ? "medium" : "regular"}
93
- color={s.status === "done" ? "muted" : "default"}
85
+ color={s.status === "done" ? "muted" : s.status === "error" ? "danger" : "default"}
94
86
  >
95
87
  {s.label}
96
88
  </Text>
@@ -109,83 +101,71 @@ export function AgentRun(props: AgentRunProps) {
109
101
  );
110
102
  }
111
103
 
112
- const NODE = 18;
104
+ const NODE_STATUS: Record<"done" | "error", { color: string; icon: IconName }> = {
105
+ done: { color: solid("emerald"), icon: "check" },
106
+ error: { color: solid("red"), icon: "circle-alert" },
107
+ };
113
108
 
114
- /** One uniform node; status drives the fill. The running node pulses (a violet
115
- * halo) — the live signal that distinguishes a feed from a static list. */
116
109
  function StepNode({ status }: { status: AgentStepStatus }) {
117
- if (status === "done") {
110
+ if (status === "running") {
118
111
  return (
119
- <View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
120
- <Icon name="check" size={11} color={colors.background} />
112
+ <View style={[styles.node, { backgroundColor: tint("blue", 0.12) }]}>
113
+ <ActivityIndicator size={12} color={solid("blue")} />
121
114
  </View>
122
115
  );
123
116
  }
124
- if (status === "error") {
117
+ const s = NODE_STATUS[status];
118
+ return (
119
+ <View style={[styles.node, { backgroundColor: tint(status === "done" ? "emerald" : "red", 0.12) }]}>
120
+ <Icon name={s.icon} size={13} color={s.color} />
121
+ </View>
122
+ );
123
+ }
124
+
125
+ function HeaderStatus({ state, streaming }: { state: "streaming" | "done" | "error"; streaming: boolean }) {
126
+ if (streaming) {
125
127
  return (
126
- <View style={[styles.disc, { backgroundColor: solid("red") }]}>
127
- <Icon name="x" size={11} color={colors.background} />
128
+ <View style={styles.headerStatus}>
129
+ <ActivityIndicator size={12} color={solid("blue")} />
130
+ <Text size="xs" weight="medium" style={{ color: solid("blue") }}>
131
+ Working
132
+ </Text>
128
133
  </View>
129
134
  );
130
135
  }
131
- return <PulseNode />;
132
- }
133
-
134
- function PulseNode() {
135
- const pulse = useRef(new Animated.Value(0)).current;
136
- useEffect(() => {
137
- const loop = Animated.loop(
138
- Animated.timing(pulse, {
139
- toValue: 1,
140
- duration: 1400,
141
- easing: Easing.out(Easing.ease),
142
- useNativeDriver: true,
143
- }),
136
+ if (state === "error") {
137
+ return (
138
+ <View style={styles.headerStatus}>
139
+ <Icon name="circle-alert" size={13} color={solid("red")} />
140
+ <Text size="xs" weight="medium" color="danger">
141
+ Stopped
142
+ </Text>
143
+ </View>
144
144
  );
145
- loop.start();
146
- return () => loop.stop();
147
- }, [pulse]);
145
+ }
148
146
  return (
149
- <View style={styles.disc}>
150
- <Animated.View
151
- style={[
152
- styles.halo,
153
- {
154
- opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.45, 0] }),
155
- transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.7, 2.1] }) }],
156
- },
157
- ]}
158
- />
159
- <View style={[styles.disc, { backgroundColor: solid("violet"), position: "absolute" }]}>
160
- <View style={styles.runningDot} />
161
- </View>
147
+ <View style={styles.headerStatus}>
148
+ <Icon name="circle-check" size={13} color={solid("emerald")} />
149
+ <Text size="xs" weight="medium" style={{ color: solid("emerald") }}>
150
+ Done
151
+ </Text>
162
152
  </View>
163
153
  );
164
154
  }
165
155
 
156
+ const NODE = 22;
157
+
166
158
  const styles = StyleSheet.create({
167
- header: { flexDirection: "row", alignItems: "center", gap: 8 },
168
- glyph: {
169
- width: 22,
170
- height: 22,
171
- borderRadius: 6,
172
- alignItems: "center",
173
- justifyContent: "center",
174
- backgroundColor: tint("violet", 0.12),
175
- },
159
+ header: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 20 },
160
+ headerStatus: { flexDirection: "row", alignItems: "center", gap: 5 },
176
161
  item: { flexDirection: "row", gap: 12 },
177
162
  spineCol: { width: NODE, alignItems: "center", paddingTop: 1 },
178
- disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
179
- halo: { position: "absolute", width: NODE, height: NODE, borderRadius: NODE / 2, backgroundColor: solid("violet") },
180
- runningDot: { width: 6, height: 6, borderRadius: 999, backgroundColor: colors.background },
181
163
  spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
182
- contentCol: { flex: 1, paddingTop: 0 },
164
+ contentCol: { flex: 1, paddingTop: 2 },
183
165
  contentGap: { paddingBottom: 14 },
166
+ node: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
184
167
  toolChip: {
185
- flexDirection: "row",
186
- alignItems: "center",
187
168
  alignSelf: "flex-start",
188
- gap: 6,
189
169
  paddingHorizontal: 8,
190
170
  paddingVertical: 3,
191
171
  borderRadius: 6,