@lotics/ui 4.1.0 → 4.3.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,165 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { colors, solid, tint } from "@lotics/ui/colors";
4
+ import { Text } from "@lotics/ui/text";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Icon } from "@lotics/ui/icon";
7
+ import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
8
+ import { SegmentedControl } from "@lotics/ui/segmented_control";
9
+ import { TextInputField } from "@lotics/ui/text_input_field";
10
+ import { DotsIndicator } from "@lotics/ui/dots_indicator";
11
+ import { CompletionState } from "@lotics/ui/completion_state";
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Template · AI draft generation — a FOURTH AI workflow shape: the agent writes
15
+ // a first DRAFT from context, and the human refines it. The feedback differs
16
+ // from the others — the OUTPUT itself streams (the reply text fills in live),
17
+ // then becomes an editable surface the person edits before sending. Tone +
18
+ // length steer the generation; Regenerate re-drafts. AI drafts, the human owns
19
+ // the final words. All mock — the recipe, not a model.
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ type Tone = "friendly" | "formal";
23
+ type Length = "short" | "detailed";
24
+
25
+ const INBOUND =
26
+ "Hi — we're launching a new product line and need about 5,000 corrugated mailers " +
27
+ "(FEFCO 0427) per month. Could you share pricing and a lead time?";
28
+
29
+ const OPENING: Record<Tone, string> = {
30
+ friendly: "Hi Maya,\n\nThanks for reaching out — exciting to hear about the new line!",
31
+ formal: "Dear Ms. Tran,\n\nThank you for your enquiry regarding corrugated mailers.",
32
+ };
33
+ const OFFER: Record<Length, string> = {
34
+ short:
35
+ "For 5,000 FEFCO 0427 mailers a month we can offer 8,200₫/unit at that volume, with a 7–10 day lead time after artwork sign-off.",
36
+ detailed:
37
+ "For 5,000 FEFCO 0427 mailers a month, our volume price is 8,200₫/unit (B-flute, 1-colour print). Lead time is 7–10 working days after artwork sign-off, and the first run includes the cutting die at no charge. We can hold this pricing for 30 days, and step it down again past 10,000 units a month.",
38
+ };
39
+ const CLOSE: Record<Tone, string> = {
40
+ friendly: "Happy to send samples whenever you're ready — just say the word.\n\nBest,\nLan",
41
+ formal: "We would be glad to provide samples upon request.\n\nKind regards,\nLan Nguyen\nSunrise Packaging",
42
+ };
43
+ const draftFor = (tone: Tone, length: Length) =>
44
+ `${OPENING[tone]}\n\n${OFFER[length]}\n\n${CLOSE[tone]}`;
45
+
46
+ export function TplDraft() {
47
+ const [phase, setPhase] = useState<"idle" | "drafting" | "ready" | "sent">("idle");
48
+ const [tone, setTone] = useState<Tone>("friendly");
49
+ const [length, setLength] = useState<Length>("short");
50
+ const [draft, setDraft] = useState("");
51
+ const [target, setTarget] = useState("");
52
+ const [revealed, setRevealed] = useState(0);
53
+
54
+ // Stream the draft text in, word by word, then hand it to the editor.
55
+ useEffect(() => {
56
+ if (phase !== "drafting") return;
57
+ const words = target.split(" ");
58
+ if (revealed >= words.length) {
59
+ setDraft(target);
60
+ setPhase("ready");
61
+ return;
62
+ }
63
+ const t = setTimeout(() => setRevealed((r) => r + 2), revealed === 0 ? 200 : 45);
64
+ return () => clearTimeout(t);
65
+ }, [phase, revealed, target]);
66
+
67
+ const startDraft = () => {
68
+ setTarget(draftFor(tone, length));
69
+ setRevealed(0);
70
+ setDraft("");
71
+ setPhase("drafting");
72
+ };
73
+
74
+ const streamed = phase === "drafting" ? target.split(" ").slice(0, revealed).join(" ") : "";
75
+
76
+ return (
77
+ <ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 80 }}>
78
+ <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
79
+ <View style={{ gap: 2 }}>
80
+ <Text size="xl" weight="semibold">Reply to inquiry</Text>
81
+ <Text size="sm" color="muted">The agent drafts a reply from the message; you set the tone, then edit before sending.</Text>
82
+ </View>
83
+
84
+ <Card style={{ padding: 0 }}>
85
+ <CardHeader><CardHeaderTitle>Inbound</CardHeaderTitle></CardHeader>
86
+ <CardBody>
87
+ <View style={{ flexDirection: "row", gap: 10 }}>
88
+ <View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: colors.zinc[200], alignItems: "center", justifyContent: "center" }}>
89
+ <Text size="xs" weight="semibold" color="muted">MT</Text>
90
+ </View>
91
+ <Text size="sm" style={{ flex: 1 }}>{INBOUND}</Text>
92
+ </View>
93
+ </CardBody>
94
+ </Card>
95
+
96
+ {phase === "sent" ? (
97
+ <Card>
98
+ <CompletionState title="Reply sent" summary="Your edited draft was sent to Maya Tran.">
99
+ <Button title="Draft another" color="secondary" shape="rounded" onPress={() => { setPhase("idle"); setDraft(""); }} />
100
+ </CompletionState>
101
+ </Card>
102
+ ) : (
103
+ <Card style={{ padding: 0 }}>
104
+ <CardHeader>
105
+ <CardHeaderTitle info="Tone and length steer the draft; you edit the result before it goes out.">Your reply</CardHeaderTitle>
106
+ </CardHeader>
107
+ <CardBody>
108
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 16 }}>
109
+ <View style={{ gap: 6 }}>
110
+ <Text size="xs" color="muted" transform="uppercase">Tone</Text>
111
+ <SegmentedControl
112
+ accessibilityLabel="Tone"
113
+ options={[{ label: "Friendly", value: "friendly" }, { label: "Formal", value: "formal" }]}
114
+ value={tone}
115
+ onValueChange={setTone}
116
+ disabled={phase === "drafting"}
117
+ />
118
+ </View>
119
+ <View style={{ gap: 6 }}>
120
+ <Text size="xs" color="muted" transform="uppercase">Length</Text>
121
+ <SegmentedControl
122
+ accessibilityLabel="Length"
123
+ options={[{ label: "Short", value: "short" }, { label: "Detailed", value: "detailed" }]}
124
+ value={length}
125
+ onValueChange={setLength}
126
+ disabled={phase === "drafting"}
127
+ />
128
+ </View>
129
+ </View>
130
+
131
+ {phase === "idle" ? (
132
+ <View style={{ alignItems: "flex-start", paddingTop: 4 }}>
133
+ <Button title="Draft a reply" color="primary" shape="rounded" icon="sparkles" onPress={startDraft} />
134
+ </View>
135
+ ) : null}
136
+
137
+ {phase === "drafting" ? (
138
+ <View style={{ borderWidth: 1, borderColor: tint("violet", 0.35), borderRadius: 10, padding: 14, gap: 10, backgroundColor: tint("violet", 0.03) }}>
139
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
140
+ <Icon name="sparkles" size={13} color={solid("violet")} />
141
+ <Text size="xs" color="muted" style={{ flex: 1 }}>Drafting…</Text>
142
+ <DotsIndicator size={5} color={solid("violet")} />
143
+ </View>
144
+ <Text size="sm">{streamed}</Text>
145
+ </View>
146
+ ) : null}
147
+
148
+ {phase === "ready" ? (
149
+ <TextInputField value={draft} onChangeText={setDraft} multiline numberOfLines={12} autoGrow />
150
+ ) : null}
151
+ </CardBody>
152
+
153
+ {phase === "ready" ? (
154
+ <CardFooter>
155
+ <View style={{ flex: 1 }} />
156
+ <Button title="Regenerate" color="secondary" shape="rounded" icon="rotate-ccw" onPress={startDraft} />
157
+ <Button title="Send reply" color="primary" shape="rounded" onPress={() => setPhase("sent")} />
158
+ </CardFooter>
159
+ ) : null}
160
+ </Card>
161
+ )}
162
+ </View>
163
+ </ScrollView>
164
+ );
165
+ }
@@ -0,0 +1,186 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { colors } from "@lotics/ui/colors";
4
+ import { Text } from "@lotics/ui/text";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { LinkButton } from "@lotics/ui/link_button";
7
+ import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
8
+ import { FileDropzone } from "@lotics/ui/file_dropzone";
9
+ import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
10
+ import { AssistedField } from "@lotics/ui/assisted_field";
11
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
12
+ import { Sources } from "@lotics/ui/sources";
13
+ import { CompletionState } from "@lotics/ui/completion_state";
14
+ import { type ConfidenceLevel } from "@lotics/ui/confidence";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Template · AI extract & confirm — a DIFFERENT AI workflow from the design
18
+ // assistant: a one-shot data-capture pass, no iteration. Drop a document → the
19
+ // agent extracts structured fields (AgentRun) → every field lands as a tinted
20
+ // ESTIMATE the human confirms or overrides (AssistedField) → submit once all
21
+ // stand. The provenance is visible the whole way: nothing is committed until a
22
+ // person stood behind each value. All mock.
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ type ScriptStep = Omit<AgentRunStep, "status">;
26
+ type Phase = "intake" | "extracting" | "review" | "done";
27
+
28
+ const EXTRACT: ScriptStep[] = [
29
+ { id: "x1", label: "Reading the document", detail: "A 2-page supplier invoice (PDF)." },
30
+ { id: "x2", label: "extract_fields", kind: "tool" },
31
+ { id: "x3", label: "Matching the vendor", detail: "Acme Packaging Co. — found in your contacts." },
32
+ { id: "x4", label: "Reading the totals", detail: "Subtotal, VAT 8%, grand total." },
33
+ ];
34
+
35
+ interface Field {
36
+ key: string;
37
+ label: string;
38
+ value: string;
39
+ unit?: string;
40
+ conf: ConfidenceLevel;
41
+ }
42
+ const FIELDS: Field[] = [
43
+ { key: "vendor", label: "Vendor", value: "Acme Packaging Co.", conf: "high" },
44
+ { key: "invoice", label: "Invoice no.", value: "INV-2026-0412", conf: "high" },
45
+ { key: "date", label: "Invoice date", value: "2026-06-10", conf: "medium" },
46
+ { key: "taxid", label: "Tax ID (MST)", value: "0312345678", conf: "medium" },
47
+ { key: "total", label: "Total", value: "48,200,000", unit: "₫", conf: "high" },
48
+ ];
49
+
50
+ const initialValues = () => Object.fromEntries(FIELDS.map((f) => [f.key, f.value]));
51
+
52
+ export function TplExtract() {
53
+ const [phase, setPhase] = useState<Phase>("intake");
54
+ const [confirmed, setConfirmed] = useState<Record<string, boolean>>({});
55
+ // The agent's values are estimates the human can override. `editing` is the
56
+ // field whose AssistedField has swapped to an inline input (the `onEdit`
57
+ // contract); `values` holds the live value, overridden on save.
58
+ const [values, setValues] = useState<Record<string, string>>(initialValues);
59
+ const [editing, setEditing] = useState<string | null>(null);
60
+
61
+ const [revealed, setRevealed] = useState(0);
62
+ const [streaming, setStreaming] = useState(false);
63
+ useEffect(() => {
64
+ if (!streaming) return;
65
+ if (revealed >= EXTRACT.length) {
66
+ const t = setTimeout(() => {
67
+ setStreaming(false);
68
+ setPhase("review");
69
+ }, 650);
70
+ return () => clearTimeout(t);
71
+ }
72
+ const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 60 : 820);
73
+ return () => clearTimeout(t);
74
+ }, [streaming, revealed]);
75
+
76
+ const steps: AgentRunStep[] = EXTRACT.slice(0, revealed).map((s, i) => ({
77
+ ...s,
78
+ status: streaming && revealed < EXTRACT.length && i === revealed - 1 ? "running" : "done",
79
+ }));
80
+
81
+ const start = () => {
82
+ setRevealed(0);
83
+ setStreaming(true);
84
+ setPhase("extracting");
85
+ };
86
+ const reset = () => {
87
+ setStreaming(false);
88
+ setRevealed(0);
89
+ setConfirmed({});
90
+ setValues(initialValues());
91
+ setEditing(null);
92
+ setPhase("intake");
93
+ };
94
+
95
+ const allConfirmed = FIELDS.every((f) => confirmed[f.key]);
96
+
97
+ return (
98
+ <ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
99
+ <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
100
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
101
+ <View style={{ flex: 1, gap: 2 }}>
102
+ <Text size="xl" weight="semibold">Capture invoice</Text>
103
+ <Text size="sm" color="muted">Drop a document; the agent extracts the fields, you confirm each before saving.</Text>
104
+ </View>
105
+ {phase !== "intake" ? <Button title="Start over" color="muted" shape="rounded" icon="rotate-ccw" onPress={reset} /> : null}
106
+ </View>
107
+
108
+ {phase === "intake" ? (
109
+ <Card style={{ padding: 0 }}>
110
+ <CardHeader><CardHeaderTitle info="Extraction is an estimate — every field is confirmable before anything is stored.">Source document</CardHeaderTitle></CardHeader>
111
+ <CardBody>
112
+ <FileDropzone onFiles={start} accept="application/pdf,image/*" height={200} label="Drop an invoice" hint="or click to choose · PDF, JPG, PNG" dropLabel="Drop to extract" />
113
+ </CardBody>
114
+ </Card>
115
+ ) : null}
116
+
117
+ {phase === "extracting" ? (
118
+ <Card>
119
+ <AgentRun title="Extracting fields" steps={steps} state={streaming ? "streaming" : "done"} />
120
+ </Card>
121
+ ) : null}
122
+
123
+ {phase === "review" ? (
124
+ <Card style={{ padding: 0 }}>
125
+ <CardHeader>
126
+ <CardHeaderTitle info="Each value is an AI estimate until you confirm it. Override any that read wrong.">Extracted fields</CardHeaderTitle>
127
+ <CardHeaderMeta>{`${Object.values(confirmed).filter(Boolean).length}/${FIELDS.length}`}</CardHeaderMeta>
128
+ </CardHeader>
129
+ <CardBody>
130
+ {FIELDS.map((f) =>
131
+ editing === f.key ? (
132
+ <View key={f.key} style={{ flexDirection: "row", alignItems: "center", gap: 16, paddingVertical: 8 }}>
133
+ <Text size="sm" color="muted" style={{ width: 132 }}>{f.label}</Text>
134
+ <View style={{ flex: 1 }}>
135
+ <InlineTextInput
136
+ value={values[f.key]}
137
+ accessibilityLabel={`Edit ${f.label}`}
138
+ onSave={(next) => {
139
+ // Override the AI estimate; a value the human typed is
140
+ // confirmed by definition.
141
+ setValues((v) => ({ ...v, [f.key]: next }));
142
+ setConfirmed((c) => ({ ...c, [f.key]: true }));
143
+ setEditing(null);
144
+ }}
145
+ />
146
+ </View>
147
+ </View>
148
+ ) : (
149
+ <AssistedField
150
+ key={f.key}
151
+ label={f.label}
152
+ value={values[f.key]}
153
+ unit={f.unit}
154
+ estimated={!confirmed[f.key]}
155
+ confidence={f.conf}
156
+ onConfirm={() => setConfirmed((c) => ({ ...c, [f.key]: true }))}
157
+ onEdit={() => setEditing(f.key)}
158
+ />
159
+ ),
160
+ )}
161
+ <Sources
162
+ label="Extracted from"
163
+ onOpen={() => {}}
164
+ sources={[{ id: "doc", label: "invoice-0412.pdf", kind: "document", detail: "2 pages" }]}
165
+ />
166
+ </CardBody>
167
+ <CardFooter>
168
+ <View style={{ flex: 1 }}>
169
+ {!allConfirmed ? <LinkButton title="Confirm all" onPress={() => setConfirmed(Object.fromEntries(FIELDS.map((f) => [f.key, true])))} /> : null}
170
+ </View>
171
+ <Button title="Save invoice" color="primary" shape="rounded" disabled={!allConfirmed} onPress={() => setPhase("done")} />
172
+ </CardFooter>
173
+ </Card>
174
+ ) : null}
175
+
176
+ {phase === "done" ? (
177
+ <Card>
178
+ <CompletionState title="Invoice saved" summary="All five fields confirmed and written to the ledger.">
179
+ <Button title="Capture another" color="secondary" shape="rounded" onPress={reset} />
180
+ </CompletionState>
181
+ </Card>
182
+ ) : null}
183
+ </View>
184
+ </ScrollView>
185
+ );
186
+ }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
+ "./option_badge": "./src/option_badge.tsx",
9
+ "./member_chip": "./src/member_chip.tsx",
10
+ "./member_select": "./src/member_select.tsx",
11
+ "./inline_member_select": "./src/inline_member_select.tsx",
8
12
  "./mime": "./src/mime.ts",
9
13
  "./download": "./src/download.ts",
10
14
  "./file_picker": "./src/file_picker.ts",
@@ -54,6 +58,15 @@
54
58
  "./picker_menu": "./src/picker_menu.tsx",
55
59
  "./text": "./src/text.tsx",
56
60
  "./activity_indicator": "./src/activity_indicator.tsx",
61
+ "./agent_run": "./src/agent_run.tsx",
62
+ "./agent_progress": "./src/agent_progress.tsx",
63
+ "./prompt_field": "./src/prompt_field.tsx",
64
+ "./confidence": "./src/confidence.tsx",
65
+ "./suggestion": "./src/suggestion.tsx",
66
+ "./assisted_field": "./src/assisted_field.tsx",
67
+ "./change_review": "./src/change_review.tsx",
68
+ "./clarify": "./src/clarify.tsx",
69
+ "./sources": "./src/sources.tsx",
57
70
  "./icon": "./src/icon.tsx",
58
71
  "./dynamic_icon": {
59
72
  "react-native": "./src/dynamic_icon.tsx",
@@ -0,0 +1,71 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { WaveAvatar } from "./wave_avatar";
7
+ import { PressableHighlight } from "./pressable_highlight";
8
+ import { AgentRun, type AgentRunStep } from "./agent_run";
9
+
10
+ export interface AgentProgressProps {
11
+ steps: AgentRunStep[];
12
+ state?: "streaming" | "done" | "error";
13
+ /** The collapsed-pill label. Defaults to the running step's label (or
14
+ * "Working…" / "Done"). */
15
+ label?: string;
16
+ defaultExpanded?: boolean;
17
+ }
18
+
19
+ /**
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.
25
+ */
26
+ export function AgentProgress(props: AgentProgressProps) {
27
+ const { steps, state = "streaming", label, defaultExpanded } = props;
28
+ const [expanded, setExpanded] = useState(defaultExpanded ?? false);
29
+
30
+ const running = steps.filter((s) => s.status === "running").at(-1);
31
+ const compact = label ?? running?.label ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
32
+
33
+ return (
34
+ <View style={{ gap: 8 }}>
35
+ {expanded ? (
36
+ <View style={styles.panel}>
37
+ <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 14 }}>
38
+ <AgentRun steps={steps} state={state} />
39
+ </ScrollView>
40
+ </View>
41
+ ) : null}
42
+
43
+ <PressableHighlight
44
+ onPress={() => setExpanded((e) => !e)}
45
+ style={styles.pill}
46
+ accessibilityLabel={expanded ? "Hide the agent's steps" : "Show the agent's steps"}
47
+ >
48
+ <WaveAvatar animated size={24} color={solid("violet")} />
49
+ <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
50
+ {compact}
51
+ </Text>
52
+ <Icon name={expanded ? "chevron-down" : "chevron-up"} size={16} color={colors.zinc[400]} />
53
+ </PressableHighlight>
54
+ </View>
55
+ );
56
+ }
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
+ const styles = StyleSheet.create({
69
+ panel: { ...FLOAT, overflow: "hidden" },
70
+ pill: { ...FLOAT, flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 10 },
71
+ });
@@ -0,0 +1,194 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Animated, Easing, StyleSheet, View } from "react-native";
3
+ import { colors, solid, tint } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { DotsIndicator } from "./dots_indicator";
7
+
8
+ export type AgentStepStatus = "running" | "done" | "error";
9
+
10
+ export interface AgentRunStep {
11
+ id: string;
12
+ /** The action line — what the agent is doing ("Reading the image",
13
+ * "Identifying the box style"). */
14
+ label: string;
15
+ /** Streamed reasoning or result text under the label (muted). Grows as the
16
+ * model streams; the host appends to it. */
17
+ detail?: string;
18
+ status: AgentStepStatus;
19
+ /** A `tool` step renders its label in a monospace chip — it called a tool
20
+ * rather than reasoned. */
21
+ kind?: "step" | "tool";
22
+ }
23
+
24
+ export interface AgentRunProps {
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.
28
+ * Defaults to `streaming` while any step is running, else `done`. */
29
+ state?: "streaming" | "done" | "error";
30
+ /** Header label — the task the agent is performing ("Designing the dieline").
31
+ * Omit for a bare step feed with no header band. */
32
+ title?: string;
33
+ accessibilityLabel?: string;
34
+ }
35
+
36
+ /**
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.
45
+ */
46
+ export function AgentRun(props: AgentRunProps) {
47
+ const { steps, title, accessibilityLabel } = props;
48
+ const anyRunning = steps.some((s) => s.status === "running");
49
+ const state = props.state ?? (anyRunning ? "streaming" : "done");
50
+ const streaming = state === "streaming";
51
+
52
+ return (
53
+ <View accessibilityLabel={accessibilityLabel} style={{ gap: title ? 12 : 0 }}>
54
+ {title ? (
55
+ <View style={styles.header}>
56
+ <View style={styles.glyph}>
57
+ <Icon name="sparkles" size={13} color={solid("violet")} />
58
+ </View>
59
+ <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
60
+ {title}
61
+ </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
+ )}
69
+ </View>
70
+ ) : null}
71
+
72
+ <View>
73
+ {steps.map((s, i) => {
74
+ const last = i === steps.length - 1;
75
+ return (
76
+ <View key={s.id} style={styles.item}>
77
+ <View style={styles.spineCol}>
78
+ <StepNode status={s.status} />
79
+ {!last ? <View style={styles.spine} /> : null}
80
+ </View>
81
+ <View style={[styles.contentCol, !last ? styles.contentGap : null]}>
82
+ {s.kind === "tool" ? (
83
+ <View style={styles.toolChip}>
84
+ <Icon name="square-check" size={11} color={colors.zinc[500]} />
85
+ <Text size="xs" color="muted" numberOfLines={1}>
86
+ {s.label}
87
+ </Text>
88
+ </View>
89
+ ) : (
90
+ <Text
91
+ size="sm"
92
+ weight={s.status === "running" ? "medium" : "regular"}
93
+ color={s.status === "done" ? "muted" : "default"}
94
+ >
95
+ {s.label}
96
+ </Text>
97
+ )}
98
+ {s.detail ? (
99
+ <Text size="xs" color="muted" style={{ marginTop: 2 }}>
100
+ {s.detail}
101
+ </Text>
102
+ ) : null}
103
+ </View>
104
+ </View>
105
+ );
106
+ })}
107
+ </View>
108
+ </View>
109
+ );
110
+ }
111
+
112
+ const NODE = 18;
113
+
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
+ function StepNode({ status }: { status: AgentStepStatus }) {
117
+ if (status === "done") {
118
+ return (
119
+ <View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
120
+ <Icon name="check" size={11} color={colors.background} />
121
+ </View>
122
+ );
123
+ }
124
+ if (status === "error") {
125
+ return (
126
+ <View style={[styles.disc, { backgroundColor: solid("red") }]}>
127
+ <Icon name="x" size={11} color={colors.background} />
128
+ </View>
129
+ );
130
+ }
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
+ }),
144
+ );
145
+ loop.start();
146
+ return () => loop.stop();
147
+ }, [pulse]);
148
+ 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>
162
+ </View>
163
+ );
164
+ }
165
+
166
+ 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
+ },
176
+ item: { flexDirection: "row", gap: 12 },
177
+ 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
+ spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
182
+ contentCol: { flex: 1, paddingTop: 0 },
183
+ contentGap: { paddingBottom: 14 },
184
+ toolChip: {
185
+ flexDirection: "row",
186
+ alignItems: "center",
187
+ alignSelf: "flex-start",
188
+ gap: 6,
189
+ paddingHorizontal: 8,
190
+ paddingVertical: 3,
191
+ borderRadius: 6,
192
+ backgroundColor: colors.zinc[100],
193
+ },
194
+ });