@lotics/ui 4.3.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.
- package/AGENTS.md +98 -28
- package/examples/tpl_assistant.tsx +6 -6
- package/examples/tpl_briefing.tsx +121 -0
- package/examples/tpl_compare.tsx +133 -0
- package/examples/tpl_crosscheck.tsx +120 -0
- package/examples/tpl_dieline.tsx +218 -55
- package/examples/tpl_draft.tsx +7 -9
- package/examples/tpl_extract.tsx +91 -92
- package/examples/tpl_lookup.tsx +288 -0
- package/examples/tpl_match.tsx +123 -0
- package/examples/tpl_triage.tsx +112 -0
- package/package.json +13 -3
- package/src/agent_progress.tsx +35 -23
- package/src/agent_run.tsx +60 -80
- package/src/change_review.tsx +190 -110
- package/src/choice_list.tsx +63 -0
- package/src/clarify.tsx +19 -39
- package/src/confidence.tsx +30 -8
- package/src/discrepancy.tsx +114 -0
- package/src/finding.tsx +104 -0
- package/src/match_row.tsx +133 -0
- package/src/prompt_field.tsx +47 -89
- package/src/record_review.tsx +149 -0
- package/src/scored_option.tsx +139 -0
- package/src/sources.tsx +38 -21
- package/src/spec_list.tsx +81 -0
- package/src/suggestion.tsx +35 -45
- package/src/triage_row.tsx +99 -0
- package/src/assisted_field.tsx +0 -93
|
@@ -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.
|
|
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",
|
|
@@ -169,7 +176,10 @@
|
|
|
169
176
|
"./screen_router": "./src/screen_router.tsx",
|
|
170
177
|
"./route_matching": "./src/route_matching.ts",
|
|
171
178
|
"./menu_title": "./src/menu_title.tsx",
|
|
172
|
-
"./avatar":
|
|
179
|
+
"./avatar": {
|
|
180
|
+
"react-native": "./src/avatar.tsx",
|
|
181
|
+
"default": "./src/avatar.web.tsx"
|
|
182
|
+
},
|
|
173
183
|
"./wave_avatar": {
|
|
174
184
|
"react-native": "./src/wave_avatar.tsx",
|
|
175
185
|
"default": "./src/wave_avatar.web.tsx"
|
package/src/agent_progress.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
-
import { 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
|
|
21
|
-
* + the current step's label) that EXPANDS on press to the full
|
|
22
|
-
* stream. It's the "working" state of a composer on a canvas app: the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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:
|
|
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={
|
|
49
|
-
<Text size="sm" weight="medium" style={{ flex: 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
|
-
|
|
70
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
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.
|
|
39
|
-
* transparent-work surface
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
<
|
|
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
|
|
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 === "
|
|
110
|
+
if (status === "running") {
|
|
118
111
|
return (
|
|
119
|
-
<View style={[styles.
|
|
120
|
-
<
|
|
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
|
-
|
|
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={
|
|
127
|
-
<
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
146
|
-
return () => loop.stop();
|
|
147
|
-
}, [pulse]);
|
|
145
|
+
}
|
|
148
146
|
return (
|
|
149
|
-
<View style={styles.
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|