@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.
- package/AGENTS.md +97 -6
- package/examples/tpl_assistant.tsx +141 -0
- package/examples/tpl_dieline.tsx +252 -0
- package/examples/tpl_draft.tsx +165 -0
- package/examples/tpl_extract.tsx +186 -0
- package/package.json +14 -1
- package/src/agent_progress.tsx +71 -0
- package/src/agent_run.tsx +194 -0
- package/src/assisted_field.tsx +93 -0
- package/src/change_review.tsx +166 -0
- package/src/chip_group.tsx +16 -13
- package/src/clarify.tsx +68 -0
- package/src/colors.test.ts +45 -0
- package/src/colors.ts +25 -0
- package/src/confidence.tsx +31 -0
- package/src/file_dropzone.tsx +5 -3
- package/src/inline_edit.tsx +12 -5
- package/src/inline_member_select.tsx +62 -0
- package/src/inline_select.tsx +6 -2
- package/src/member_chip.tsx +51 -0
- package/src/member_select.tsx +81 -0
- package/src/option_badge.tsx +58 -0
- package/src/prompt_field.tsx +150 -0
- package/src/sources.tsx +92 -0
- package/src/suggestion.tsx +102 -0
|
@@ -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.
|
|
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
|
+
});
|