@lotics/ui 13.10.0 → 14.0.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/docs/ai_patterns.md +42 -30
- package/docs/catalog.md +16 -6
- package/examples/tpl_dieline.tsx +21 -18
- package/examples/tpl_documents.tsx +19 -12
- package/examples/tpl_lookup.tsx +18 -13
- package/examples/tpl_record.tsx +21 -16
- package/examples/tpl_task_board.tsx +13 -8
- package/package.json +6 -2
- package/src/agent_progress.tsx +11 -12
- package/src/agent_run.tsx +82 -121
- package/src/agent_transform.ts +102 -0
- package/src/json_panel.tsx +25 -13
- package/src/primitives_purity.test.ts +15 -1
package/docs/ai_patterns.md
CHANGED
|
@@ -93,26 +93,27 @@ transition — never leave a live button waiting on a network round-trip:
|
|
|
93
93
|
## Show the work — `AgentRun`
|
|
94
94
|
|
|
95
95
|
`AgentRun` (`@lotics/ui/agent_run`) is a live feed of the agent's work as a TIMELINE. The prop is
|
|
96
|
-
|
|
96
|
+
the agent message's **ai-sdk `parts`** (`UIMessagePart[]`) — the same wire shape chat and app
|
|
97
|
+
agents emit; `@lotics/ui` owns the transform from parts to the render timeline, so there is no
|
|
98
|
+
bespoke transcript type. It renders three part kinds:
|
|
97
99
|
|
|
98
|
-
|
|
|
99
|
-
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
text, NOT all text on top of a flat step list.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
**Activity grouping.** Consecutive `step` items fold into ONE group. While the agent is mid-tools
|
|
100
|
+
| Part | Renders as |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `text` | Answer prose (`Markdown`), grows as it streams |
|
|
103
|
+
| `reasoning` | Thinking — COLLAPSED, a muted "Thinking" row; press to reveal the Markdown |
|
|
104
|
+
| a tool part — `dynamic-tool` (app agents) or `tool-<name>` (chat) | An activity row with a status dot |
|
|
105
|
+
|
|
106
|
+
Parts appear in the order they happened — a real run is think → text → a burst of calls → more
|
|
107
|
+
text, NOT all text on top of a flat step list. Content parts with no feed representation
|
|
108
|
+
(`source-*`, `file`, `data-*`, `step-start`, `custom`) are skipped. Transparent work, NEVER a bare spinner.
|
|
109
|
+
|
|
110
|
+
**Progressive disclosure.** The feed shows the label + state; the detail is revealed on demand. A
|
|
111
|
+
tool part's `input`/`output` are hidden in the row and **expand in place** on press — auto-built
|
|
112
|
+
Input / Output `JsonPanel`s roll out under the row (the same disclosure pattern as "Thinking",
|
|
113
|
+
never a popover). A tool in an `output-error` state flows like any other call — the dot goes
|
|
114
|
+
amber and its `errorText` shows in the expanded Error panel.
|
|
115
|
+
|
|
116
|
+
**Activity grouping.** Consecutive tool parts fold into ONE group. While the agent is mid-tools
|
|
116
117
|
the tail group is a SINGLE pulsing row whose label swaps in place as each call fires (no growing
|
|
117
118
|
stack of dots); once prose resumes the group settles: a single call becomes one done row, and a
|
|
118
119
|
multi-call group becomes a persistent "{final action} · {n} steps" HEADER (a `complete` terminal
|
|
@@ -120,17 +121,28 @@ dot — distinct from the filled `done` step dots; amber `warning` if any call e
|
|
|
120
121
|
PUT and rolls the calls out BELOW it on press. The run ALWAYS ends on the agent's text — there is
|
|
121
122
|
no global terminal node.
|
|
122
123
|
|
|
123
|
-
**Tool labels.** A
|
|
124
|
-
resolved via a built-in map of the platform record/document
|
|
125
|
-
override (localize THERE — the kit stays English); unknown
|
|
126
|
-
`stepsLabel` localizes the "{n} steps" suffix.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
124
|
+
**Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
|
|
125
|
+
or a `dynamic-tool`'s `toolName`), resolved via a built-in map of the platform record/document
|
|
126
|
+
tools + an optional `labelForTool` override (localize THERE — the kit stays English); unknown
|
|
127
|
+
names fall back to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool
|
|
128
|
+
`state` collapses ai's lifecycle to running / done / error (an approval-pending state reads as
|
|
129
|
+
running — a read-only feed has no approval control). The whole-run `state` prop
|
|
130
|
+
(`"streaming" | "done" | "error"`) defaults to `streaming` while any tool is running, else `done`.
|
|
131
|
+
|
|
132
|
+
**Errors — two kinds, both surfaced, neither shouting.** An INTERMEDIARY tool failure rides in
|
|
133
|
+
that tool's `output-error` part and flows like a normal call — the amber dot marks it (a settled
|
|
134
|
+
group's header goes amber too), the reason waits in the expanded Error panel; real runs retry and
|
|
135
|
+
move on, so the feed doesn't dramatize it. A BREAKING error that terminates the whole run lives
|
|
136
|
+
OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
|
|
137
|
+
pass it as the `error` prop and it renders as a terminal danger row under the transcript.
|
|
138
|
+
|
|
139
|
+
Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
|
|
140
|
+
for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
|
|
141
|
+
[the SDK doc](../../app-sdk/docs/ai.md) for the hook. Requires `ai` as an optional (type-only) peer.
|
|
142
|
+
**Reasoning only shows when the model produces it** — the declared model must support thinking
|
|
143
|
+
(Sonnet 4.6 / Opus 4.8, not Haiku); the renderer shows every reasoning part it's handed.
|
|
144
|
+
|
|
145
|
+
**Limitation:** the collapsed reasoning row's "Thinking" label and the expanded detail's
|
|
134
146
|
"Input"/"Output"/"Error" panel titles are currently English-only (not on the locale provider and
|
|
135
147
|
not prop-overridable); `labelForTool`/`stepsLabel` localize everything else in the feed.
|
|
136
148
|
|
package/docs/catalog.md
CHANGED
|
@@ -962,11 +962,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
962
962
|
- **`composer`** — `Composer`: the adaptive command/chat composer — a compact pill that
|
|
963
963
|
expands for long text + attachments (`pills` + attachment slots, controlled or
|
|
964
964
|
uncontrolled `value`, `onSend`/`onStop`, `sendDisabled` override).
|
|
965
|
-
- **`agent_run`** — `AgentRun` + `
|
|
966
|
-
|
|
967
|
-
`
|
|
968
|
-
|
|
969
|
-
|
|
965
|
+
- **`agent_run`** — `AgentRun` + `resolveToolMeta`: the live streaming work feed. Its prop is the
|
|
966
|
+
agent message's ai-sdk `parts` (`UIMessagePart[]` — text, reasoning, and tool parts), fed straight
|
|
967
|
+
from `useAgentRun().parts`; renders prose, a collapsed "Thinking" disclosure, and grouped tool
|
|
968
|
+
steps whose I/O expands in place on press (no popover). A per-tool failure shows amber with the
|
|
969
|
+
reason in its expanded Error panel; a run-level
|
|
970
|
+
breaking `error` (outside `parts`) renders as a terminal danger row. Needs `ai` as an optional
|
|
971
|
+
type-only peer. AI work ONLY —
|
|
972
|
+
deterministic work (template fill, an export) shows a `loading` state on its trigger and delivers
|
|
973
|
+
at once; dressing it as an agent run is theater.
|
|
974
|
+
- **`agent_transform`** — internal (no component): the one place that folds an ai-sdk `UIMessage`'s
|
|
975
|
+
`parts` into the render timeline (`AgentUIPart`, `toSegments`, `lastRunningStep`), shared by
|
|
976
|
+
`AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
|
|
977
|
+
- **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
|
|
978
|
+
`parts` prop) — a composer's working state.
|
|
970
979
|
- **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
|
|
971
980
|
high/med/low; localized via the provider.
|
|
972
981
|
- **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
|
|
@@ -1031,4 +1040,5 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1031
1040
|
a programmatic focus restore inside the suppression window doesn't re-open the editor it
|
|
1032
1041
|
just closed.
|
|
1033
1042
|
- **`json_panel`** — `JsonPanel` (`{title, value}`) + `stringifyData`: a labeled monospace
|
|
1034
|
-
panel for raw/JSON payloads (debug & developer surfaces).
|
|
1043
|
+
panel for raw/JSON payloads (debug & developer surfaces). Height-capped (240) and scrolls
|
|
1044
|
+
inside, so a huge dump (a streamed tool input) can never blow the surface open.
|
package/examples/tpl_dieline.tsx
CHANGED
|
@@ -10,7 +10,9 @@ import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
|
10
10
|
import { AgentProgress } from "@lotics/ui/agent_progress";
|
|
11
11
|
import { Composer } from "@lotics/ui/composer";
|
|
12
12
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
13
|
-
import {
|
|
13
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
14
|
+
|
|
15
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
14
16
|
|
|
15
17
|
// A real app fills the attachment from `useFileUpload` (the uploaded file's URL);
|
|
16
18
|
// this mock uses an inline placeholder so the example needs no network image.
|
|
@@ -34,20 +36,20 @@ const MOCK_PHOTO: DisplayFile = {
|
|
|
34
36
|
// carries the single Download. All mock — the recipe, not a CAD engine.
|
|
35
37
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
38
|
|
|
37
|
-
type ScriptStep =
|
|
39
|
+
type ScriptStep = { id: string; label: string };
|
|
38
40
|
type Dims = { L: number; W: number; H: number };
|
|
39
41
|
|
|
40
42
|
const RECOGNIZE: ScriptStep[] = [
|
|
41
|
-
{ id: "r1", label: "Reading the photo"
|
|
42
|
-
{ id: "r2", label: "reference_dielines.search"
|
|
43
|
-
{ id: "r3", label: "Identifying the box style"
|
|
44
|
-
{ id: "r4", label: "Estimating dimensions"
|
|
45
|
-
{ id: "r5", label: "Drafting the dieline"
|
|
43
|
+
{ id: "r1", label: "Reading the photo" },
|
|
44
|
+
{ id: "r2", label: "reference_dielines.search" },
|
|
45
|
+
{ id: "r3", label: "Identifying the box style" },
|
|
46
|
+
{ id: "r4", label: "Estimating dimensions" },
|
|
47
|
+
{ id: "r5", label: "Drafting the dieline" },
|
|
46
48
|
];
|
|
47
49
|
|
|
48
|
-
const
|
|
49
|
-
{ id: "e1", label: "Reading the request"
|
|
50
|
-
{ id: "e2", label: "Recomputing the dieline"
|
|
50
|
+
const EDIT_SCRIPT: ScriptStep[] = [
|
|
51
|
+
{ id: "e1", label: "Reading the request" },
|
|
52
|
+
{ id: "e2", label: "Recomputing the dieline" },
|
|
51
53
|
];
|
|
52
54
|
|
|
53
55
|
const ZOOM_MIN = 0.4;
|
|
@@ -105,12 +107,13 @@ export function TplDieline() {
|
|
|
105
107
|
return () => clearTimeout(t);
|
|
106
108
|
}, [run, revealed]);
|
|
107
109
|
|
|
108
|
-
const liveItems:
|
|
109
|
-
? run.script.slice(0, revealed).map((s, i) =>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
const liveItems: Part[] = run
|
|
111
|
+
? run.script.slice(0, revealed).map((s, i): Part => {
|
|
112
|
+
const running = revealed < run.script.length && i === revealed - 1;
|
|
113
|
+
return running
|
|
114
|
+
? { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "input-available", input: undefined }
|
|
115
|
+
: { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "output-available", input: undefined, output: undefined };
|
|
116
|
+
})
|
|
114
117
|
: [];
|
|
115
118
|
|
|
116
119
|
const startRecognize = () => {
|
|
@@ -135,7 +138,7 @@ export function TplDieline() {
|
|
|
135
138
|
setPhase("working");
|
|
136
139
|
setRevealed(0);
|
|
137
140
|
setRun({
|
|
138
|
-
script:
|
|
141
|
+
script: EDIT_SCRIPT,
|
|
139
142
|
onDone: () => {
|
|
140
143
|
const m = /(\d+)\s*mm/i.exec(text);
|
|
141
144
|
const delta = m ? Number(m[1]) : 5;
|
|
@@ -209,7 +212,7 @@ export function TplDieline() {
|
|
|
209
212
|
<View style={{ position: "absolute", left: 0, right: 0, bottom: 24, alignItems: "center", paddingHorizontal: 16, pointerEvents: "none" }}>
|
|
210
213
|
<View style={{ width: "100%", maxWidth: 620, pointerEvents: "auto" }}>
|
|
211
214
|
{phase === "working" ? (
|
|
212
|
-
<AgentProgress
|
|
215
|
+
<AgentProgress parts={liveItems} state="streaming" />
|
|
213
216
|
) : (
|
|
214
217
|
<Composer
|
|
215
218
|
value={prompt}
|
|
@@ -22,11 +22,14 @@ import { useSelection } from "@lotics/ui/use_selection";
|
|
|
22
22
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
23
23
|
import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
|
|
24
24
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
25
|
-
import { AgentRun
|
|
25
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
26
26
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
27
27
|
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
28
28
|
|
|
29
29
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
30
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
31
|
+
|
|
32
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
30
33
|
|
|
31
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
35
|
// Template · Document desk — THE worked example for everything document-driven
|
|
@@ -50,7 +53,7 @@ import { CompletionState } from "@lotics/ui/completion_state";
|
|
|
50
53
|
// split) and drops the toolbar button.
|
|
51
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
55
|
|
|
53
|
-
type ScriptStep =
|
|
56
|
+
type ScriptStep = { id: string; label: string; detail?: string; kind?: "tool" };
|
|
54
57
|
type Task = "extract" | "check";
|
|
55
58
|
type Phase = "fork" | "running" | "review" | "done";
|
|
56
59
|
|
|
@@ -276,14 +279,16 @@ export function TplDocuments() {
|
|
|
276
279
|
return () => clearTimeout(t);
|
|
277
280
|
}, [phase, revealed, script.length]);
|
|
278
281
|
|
|
279
|
-
const runItems:
|
|
280
|
-
{ type: "text",
|
|
281
|
-
...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i):
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
const runItems: Part[] = [
|
|
283
|
+
{ type: "text", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
|
|
284
|
+
...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): Part =>
|
|
285
|
+
i < revealed
|
|
286
|
+
? { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "output-available", input: undefined, output: undefined }
|
|
287
|
+
: { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "input-available", input: undefined },
|
|
288
|
+
),
|
|
284
289
|
];
|
|
285
290
|
if (revealed >= script.length) {
|
|
286
|
-
runItems.push({ type: "text",
|
|
291
|
+
runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
|
|
287
292
|
}
|
|
288
293
|
|
|
289
294
|
// Apply counts what actually commits: the kept fields.
|
|
@@ -498,7 +503,7 @@ export function TplDocuments() {
|
|
|
498
503
|
</View>
|
|
499
504
|
) : null}
|
|
500
505
|
|
|
501
|
-
{phase === "running" ? <AgentRun
|
|
506
|
+
{phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
|
|
502
507
|
|
|
503
508
|
{phase === "review" && task === "extract" ? (
|
|
504
509
|
<View style={{ gap: 16 }}>
|
|
@@ -695,9 +700,11 @@ export function TplDocuments() {
|
|
|
695
700
|
</View>
|
|
696
701
|
) : genRunning ? (
|
|
697
702
|
<AgentRun
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
703
|
+
parts={genSteps.slice(0, Math.min(genRevealed + 1, genSteps.length)).map((st, i): Part =>
|
|
704
|
+
i < genRevealed
|
|
705
|
+
? { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "output-available", input: undefined, output: undefined }
|
|
706
|
+
: { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "input-available", input: undefined },
|
|
707
|
+
)}
|
|
701
708
|
state={genRevealed >= genSteps.length ? "done" : "streaming"}
|
|
702
709
|
/>
|
|
703
710
|
) : !genDone ? (
|
package/examples/tpl_lookup.tsx
CHANGED
|
@@ -6,10 +6,13 @@ import { Divider } from "@lotics/ui/divider";
|
|
|
6
6
|
import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
|
|
7
7
|
import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
8
8
|
import { Composer } from "@lotics/ui/composer";
|
|
9
|
-
import { AgentRun
|
|
9
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
10
10
|
import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
11
11
|
import { Badge } from "@lotics/ui/badge";
|
|
12
12
|
import { Sources, type SourceRef } from "@lotics/ui/sources";
|
|
13
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
14
|
+
|
|
15
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
13
16
|
|
|
14
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
18
|
// Template · Answer desk — describe the goods on the LEFT, the agent RANKS the
|
|
@@ -134,13 +137,13 @@ const CANDIDATES: Candidate[] = [
|
|
|
134
137
|
},
|
|
135
138
|
];
|
|
136
139
|
|
|
137
|
-
type ScriptStep =
|
|
140
|
+
type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
|
|
138
141
|
const SCRIPT: ScriptStep[] = [
|
|
139
|
-
{ id: "r1",
|
|
140
|
-
{ id: "r2",
|
|
141
|
-
{ id: "r3",
|
|
142
|
-
{ id: "r4",
|
|
143
|
-
{ id: "r5",
|
|
142
|
+
{ id: "r1", toolName: "Reading the description", input: undefined },
|
|
143
|
+
{ id: "r2", toolName: "tariff_schedule.search" },
|
|
144
|
+
{ id: "r3", toolName: "Narrowing the heading", input: undefined },
|
|
145
|
+
{ id: "r4", toolName: "duty_rates.read" },
|
|
146
|
+
{ id: "r5", toolName: "Checking import policies", input: undefined },
|
|
144
147
|
];
|
|
145
148
|
|
|
146
149
|
export function TplLookup() {
|
|
@@ -182,11 +185,13 @@ export function TplLookup() {
|
|
|
182
185
|
};
|
|
183
186
|
|
|
184
187
|
const streaming = phase === "running" && revealed < SCRIPT.length;
|
|
185
|
-
const items:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
const items: Part[] = SCRIPT.slice(0, revealed).map((s, i): Part => {
|
|
189
|
+
const running = streaming && i === revealed - 1;
|
|
190
|
+
if (running) {
|
|
191
|
+
return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "input-available", input: s.input };
|
|
192
|
+
}
|
|
193
|
+
return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "output-available", input: s.input, output: s.output };
|
|
194
|
+
});
|
|
190
195
|
|
|
191
196
|
return (
|
|
192
197
|
<View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.white }}>
|
|
@@ -200,7 +205,7 @@ export function TplLookup() {
|
|
|
200
205
|
|
|
201
206
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
202
207
|
{/* current run */}
|
|
203
|
-
<AgentRun
|
|
208
|
+
<AgentRun parts={items} state={streaming ? "streaming" : "done"} />
|
|
204
209
|
|
|
205
210
|
{/* Matches surface only once the run settles — the agent's output,
|
|
206
211
|
never a pre-filled list; they sit at the bottom, under the run. */}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -65,10 +65,13 @@ import { SearchInput } from "@lotics/ui/search_input";
|
|
|
65
65
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
66
66
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
67
67
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
68
|
-
import { AgentRun
|
|
68
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
69
69
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
70
70
|
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
71
71
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
72
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
73
|
+
|
|
74
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
72
75
|
|
|
73
76
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
77
|
// Template · Record — THE record surface: the surface IS the editor, every
|
|
@@ -431,7 +434,7 @@ const CONTENT_MAX = 720;
|
|
|
431
434
|
// that forks into extract / cross-check / edit-with-AI. The two templates
|
|
432
435
|
// share this desk — change it in BOTH (the one divergence: generation lives
|
|
433
436
|
// in this page's output sections, in tpl_documents' toolbar dialog).
|
|
434
|
-
type ScriptStep =
|
|
437
|
+
type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
|
|
435
438
|
type Task = "extract" | "check";
|
|
436
439
|
type Phase = "fork" | "running" | "review" | "done";
|
|
437
440
|
|
|
@@ -455,15 +458,15 @@ const DOCS: Doc[] = [
|
|
|
455
458
|
];
|
|
456
459
|
|
|
457
460
|
const EXTRACT_STEPS: ScriptStep[] = [
|
|
458
|
-
{ id: "e1",
|
|
459
|
-
{ id: "e2",
|
|
460
|
-
{ id: "e3",
|
|
461
|
-
{ id: "e4",
|
|
461
|
+
{ id: "e1", toolName: "Reading the selected documents" },
|
|
462
|
+
{ id: "e2", toolName: "get_record" },
|
|
463
|
+
{ id: "e3", toolName: "Extracting field values" },
|
|
464
|
+
{ id: "e4", toolName: "Comparing against the record" },
|
|
462
465
|
];
|
|
463
466
|
const CHECK_STEPS: ScriptStep[] = [
|
|
464
|
-
{ id: "c1",
|
|
465
|
-
{ id: "c2",
|
|
466
|
-
{ id: "c3",
|
|
467
|
+
{ id: "c1", toolName: "Reading the selected documents" },
|
|
468
|
+
{ id: "c2", toolName: "get_record" },
|
|
469
|
+
{ id: "c3", toolName: "Cross-checking documents and record" },
|
|
467
470
|
];
|
|
468
471
|
|
|
469
472
|
// The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
|
|
@@ -959,14 +962,16 @@ export function TplRecord() {
|
|
|
959
962
|
return () => clearTimeout(t);
|
|
960
963
|
}, [phase, revealed, script.length]);
|
|
961
964
|
|
|
962
|
-
const runItems:
|
|
963
|
-
{ type: "text",
|
|
964
|
-
...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i):
|
|
965
|
-
|
|
966
|
-
|
|
965
|
+
const runItems: Part[] = [
|
|
966
|
+
{ type: "text", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
|
|
967
|
+
...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): Part => (
|
|
968
|
+
i < revealed
|
|
969
|
+
? { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "output-available", input: s.input, output: s.output }
|
|
970
|
+
: { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "input-available", input: s.input }
|
|
971
|
+
)),
|
|
967
972
|
];
|
|
968
973
|
if (revealed >= script.length) {
|
|
969
|
-
runItems.push({ type: "text",
|
|
974
|
+
runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
|
|
970
975
|
}
|
|
971
976
|
|
|
972
977
|
// Apply counts what actually commits: the kept fields.
|
|
@@ -2771,7 +2776,7 @@ export function TplRecord() {
|
|
|
2771
2776
|
</View>
|
|
2772
2777
|
) : null}
|
|
2773
2778
|
|
|
2774
|
-
{phase === "running" ? <AgentRun
|
|
2779
|
+
{phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
|
|
2775
2780
|
|
|
2776
2781
|
{phase === "review" && task === "extract" ? (
|
|
2777
2782
|
<View style={{ gap: 16 }}>
|
|
@@ -27,12 +27,15 @@ import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from
|
|
|
27
27
|
import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
|
|
28
28
|
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
29
29
|
import { Composer } from "@lotics/ui/composer";
|
|
30
|
-
import { AgentRun
|
|
30
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
31
31
|
import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
|
|
32
32
|
import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
|
|
33
33
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
34
34
|
import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
35
35
|
import { toISODate } from "@lotics/ui/format_date";
|
|
36
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
37
|
+
|
|
38
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
36
39
|
|
|
37
40
|
type TagOption = { value: string; label: string };
|
|
38
41
|
|
|
@@ -253,14 +256,16 @@ export function TplTaskBoard() {
|
|
|
253
256
|
return () => clearTimeout(t);
|
|
254
257
|
}, [phase, revealed]);
|
|
255
258
|
|
|
256
|
-
const runItems:
|
|
257
|
-
{ type: "text",
|
|
258
|
-
...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i):
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
const runItems: Part[] = [
|
|
260
|
+
{ type: "text", text: "I read your notes and pulled out the work." },
|
|
261
|
+
...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): Part =>
|
|
262
|
+
i < revealed
|
|
263
|
+
? { type: "dynamic-tool", toolName: s.label, toolCallId: `s${i}`, state: "output-available", input: undefined, output: undefined }
|
|
264
|
+
: { type: "dynamic-tool", toolName: s.label, toolCallId: `s${i}`, state: "input-available", input: undefined },
|
|
265
|
+
),
|
|
261
266
|
];
|
|
262
267
|
if (revealed >= DRAFT_STEPS.length) {
|
|
263
|
-
runItems.push({ type: "text",
|
|
268
|
+
runItems.push({ type: "text", text: `Drafted ${DRAFTED.length} tasks — keep the ones that look right, edit any before adding.` });
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
const propertyCols: DataGridColumn<Task>[] = [
|
|
@@ -384,7 +389,7 @@ export function TplTaskBoard() {
|
|
|
384
389
|
<Composer onSend={startDraft} placeholder="Paste your notes…" autoFocus sendLabel="Draft tasks" />
|
|
385
390
|
</View>
|
|
386
391
|
) : null}
|
|
387
|
-
{phase === "reading" ? <AgentRun
|
|
392
|
+
{phase === "reading" ? <AgentRun parts={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
|
|
388
393
|
{phase === "review" ? (
|
|
389
394
|
<View style={{ gap: 8 }}>
|
|
390
395
|
<ChangeReviewHeader title="Drafted tasks" />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -264,9 +264,13 @@
|
|
|
264
264
|
"react-dom": "^19.2.0",
|
|
265
265
|
"react-native": ">=0.85.0",
|
|
266
266
|
"react-native-svg": ">=15.0.0",
|
|
267
|
-
"react-native-web": ">=0.20.0"
|
|
267
|
+
"react-native-web": ">=0.20.0",
|
|
268
|
+
"ai": ">=7.0.0"
|
|
268
269
|
},
|
|
269
270
|
"peerDependenciesMeta": {
|
|
271
|
+
"ai": {
|
|
272
|
+
"optional": true
|
|
273
|
+
},
|
|
270
274
|
"expo-image": {
|
|
271
275
|
"optional": true
|
|
272
276
|
},
|
package/src/agent_progress.tsx
CHANGED
|
@@ -4,12 +4,16 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { WaveAvatar } from "./wave_avatar";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
-
import { AgentRun, resolveToolMeta
|
|
7
|
+
import { AgentRun, resolveToolMeta } from "./agent_run";
|
|
8
|
+
import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
|
|
8
9
|
|
|
9
10
|
export interface AgentProgressProps {
|
|
10
|
-
/** The
|
|
11
|
-
|
|
11
|
+
/** The agent message's ai-sdk `parts` — same shape `AgentRun` takes. */
|
|
12
|
+
parts: readonly AgentUIPart[];
|
|
12
13
|
state?: "streaming" | "done" | "error";
|
|
14
|
+
/** A run-level breaking error, forwarded to the expanded `AgentRun` (a terminal
|
|
15
|
+
* danger row). Lives outside `parts` — `useAgentRun().error`. */
|
|
16
|
+
error?: string;
|
|
13
17
|
/** The collapsed-pill label. Defaults to the running step's label (or
|
|
14
18
|
* "Working…" / "Done"). */
|
|
15
19
|
label?: string;
|
|
@@ -30,16 +34,11 @@ export interface AgentProgressProps {
|
|
|
30
34
|
* label, so the surface stays calm until you ask to see the steps.
|
|
31
35
|
*/
|
|
32
36
|
export function AgentProgress(props: AgentProgressProps) {
|
|
33
|
-
const {
|
|
37
|
+
const { parts, state = "streaming", error, label, labelForTool, stepsLabel, defaultExpanded } = props;
|
|
34
38
|
const [expanded, setExpanded] = useState(defaultExpanded ?? false);
|
|
35
39
|
|
|
36
|
-
const running =
|
|
37
|
-
const runningLabel =
|
|
38
|
-
running && running.type === "step"
|
|
39
|
-
? running.kind === "tool"
|
|
40
|
-
? resolveToolMeta(running.label, labelForTool).label
|
|
41
|
-
: running.label
|
|
42
|
-
: undefined;
|
|
40
|
+
const running = lastRunningStep(toSegments(parts));
|
|
41
|
+
const runningLabel = running ? resolveToolMeta(running.toolName, labelForTool).label : undefined;
|
|
43
42
|
const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
|
|
44
43
|
const streaming = state === "streaming";
|
|
45
44
|
|
|
@@ -48,7 +47,7 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
48
47
|
{expanded ? (
|
|
49
48
|
<View style={styles.panel}>
|
|
50
49
|
<ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
|
|
51
|
-
<AgentRun
|
|
50
|
+
<AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
|
|
52
51
|
</ScrollView>
|
|
53
52
|
</View>
|
|
54
53
|
) : null}
|
package/src/agent_run.tsx
CHANGED
|
@@ -5,59 +5,31 @@ import { Text } from "./text";
|
|
|
5
5
|
import { Icon, type IconName } from "./icon";
|
|
6
6
|
import { Markdown } from "./markdown";
|
|
7
7
|
import { JsonPanel, stringifyData } from "./json_panel";
|
|
8
|
-
import { Peek } from "./peek";
|
|
9
8
|
import { PressableHighlight } from "./pressable_highlight";
|
|
10
9
|
import { Marker, type StepStatus } from "./stepper";
|
|
11
10
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
12
11
|
import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
13
12
|
import { useLoticsLocale, type LoticsLocale } from "./locale";
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
|
|
20
|
-
* it to a human label via the default map / `labelForTool`. A `step` carries
|
|
21
|
-
* an already-human label. */
|
|
22
|
-
label: string;
|
|
23
|
-
status: AgentStepStatus;
|
|
24
|
-
kind?: "step" | "tool";
|
|
25
|
-
/** The tool call's input (arguments) and result. When present (and no explicit
|
|
26
|
-
* `peek`), the feed reveals them ON DEMAND — a press-to-open peek with an Input /
|
|
27
|
-
* Output panel — instead of cluttering the row; the row itself shows only the
|
|
28
|
-
* label + state. */
|
|
29
|
-
input?: unknown;
|
|
30
|
-
output?: unknown;
|
|
31
|
-
/** The tool failure message (shown in the peek when `status` is `"error"`). */
|
|
32
|
-
errorText?: string;
|
|
33
|
-
/** A note under the label — a short, human summary (a count, a result summary),
|
|
34
|
-
* never invented prose. Muted. For raw tool I/O prefer `input`/`output`. */
|
|
35
|
-
detail?: string;
|
|
36
|
-
/** Custom content for the press-to-open peek. Overrides the auto-built Input /
|
|
37
|
-
* Output panel — pass this to render the reveal yourself. */
|
|
38
|
-
peek?: ReactNode;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The ordered transcript of a run, in the order it happened: the agent's answer
|
|
43
|
-
* prose (`text`), its thinking (`reasoning`, shown collapsed / revealed on demand),
|
|
44
|
-
* and the tools it runs between prose (`step`). A real run is a timeline — text, a
|
|
45
|
-
* burst of tool calls, more text — so the feed is an ordered array, NOT a text
|
|
46
|
-
* block over a flat step list. Consecutive `step` items collapse into ONE activity
|
|
47
|
-
* group; a `step`'s `input`/`output` reveal in a peek on press.
|
|
48
|
-
*/
|
|
49
|
-
export type AgentRunItem =
|
|
50
|
-
| { type: "text"; id: string; text: string }
|
|
51
|
-
| { type: "reasoning"; id: string; text: string }
|
|
52
|
-
| ({ type: "step" } & AgentRunStep);
|
|
14
|
+
// The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
|
|
15
|
+
// transcript type. `agent_transform` is the one place that folds parts into the
|
|
16
|
+
// timeline; this file is the renderer.
|
|
17
|
+
import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
|
|
53
18
|
|
|
54
19
|
export interface AgentRunProps {
|
|
55
|
-
/** The
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* to `streaming` while any
|
|
20
|
+
/** The agent message's ai-sdk `parts` — the ordered transcript, rendered in
|
|
21
|
+
* place. Chat passes `message.parts`; an app passes `useAgentRun().parts`. */
|
|
22
|
+
parts: readonly AgentUIPart[];
|
|
23
|
+
/** Whole-run state. `streaming` keeps the tail activity group pulsing;
|
|
24
|
+
* `done`/`error` settle every group. Defaults to `streaming` while any tool
|
|
25
|
+
* step is running. */
|
|
60
26
|
state?: "streaming" | "done" | "error";
|
|
27
|
+
/** A run-level BREAKING error — the message that terminated the run (a stream
|
|
28
|
+
* error, an API failure, a hit quota). It lives OUTSIDE `parts` (chat stores it
|
|
29
|
+
* in the message's `errors`; `useAgentRun().error` carries it), so pass it here;
|
|
30
|
+
* it renders as a terminal danger row under the transcript. A per-tool failure
|
|
31
|
+
* is different — it rides in that tool's own `output-error` part. */
|
|
32
|
+
error?: string;
|
|
61
33
|
/** Localize / override a TOOL step's display label by its raw tool name
|
|
62
34
|
* (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
|
|
63
35
|
* back to the built-in label. */
|
|
@@ -107,36 +79,9 @@ export function resolveToolMeta(
|
|
|
107
79
|
};
|
|
108
80
|
}
|
|
109
81
|
|
|
110
|
-
/** Resolve a step's display label
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// A run is a timeline of segments: prose, thinking, then a group of consecutive
|
|
117
|
-
// tool calls, then more prose. Folding the flat item list into segments is what
|
|
118
|
-
// lets the feed interleave instead of forcing all text to the top.
|
|
119
|
-
type Segment =
|
|
120
|
-
| { kind: "text"; id: string; text: string }
|
|
121
|
-
| { kind: "reasoning"; id: string; text: string }
|
|
122
|
-
| { kind: "group"; id: string; steps: AgentRunStep[] };
|
|
123
|
-
|
|
124
|
-
function toSegments(items: AgentRunItem[]): Segment[] {
|
|
125
|
-
const segments: Segment[] = [];
|
|
126
|
-
for (const item of items) {
|
|
127
|
-
if (item.type === "text" || item.type === "reasoning") {
|
|
128
|
-
segments.push({ kind: item.type, id: item.id, text: item.text });
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const { type: _t, ...step } = item;
|
|
132
|
-
const tail = segments[segments.length - 1];
|
|
133
|
-
if (tail && tail.kind === "group") {
|
|
134
|
-
segments[segments.length - 1] = { ...tail, steps: [...tail.steps, step] };
|
|
135
|
-
} else {
|
|
136
|
-
segments.push({ kind: "group", id: item.id, steps: [step] });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return segments;
|
|
82
|
+
/** Resolve a tool step's display label from its raw tool name. */
|
|
83
|
+
function stepLabel(s: AgentStep, labelForTool?: (toolName: string) => string | undefined): string {
|
|
84
|
+
return resolveToolMeta(s.toolName, labelForTool).label;
|
|
140
85
|
}
|
|
141
86
|
|
|
142
87
|
const INK = colors.zinc[700];
|
|
@@ -147,15 +92,14 @@ const INK = colors.zinc[700];
|
|
|
147
92
|
* tail group is a SINGLE pulsing row whose label swaps in place as each call
|
|
148
93
|
* fires (not a growing stack of dots); once prose resumes the group settles into
|
|
149
94
|
* one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
|
|
150
|
-
* between. Text segments render as prose
|
|
151
|
-
*
|
|
95
|
+
* between. Text segments render as prose. The run always ends on the agent's text.
|
|
96
|
+
* Pair with `Composer` + `ChangeReview`.
|
|
152
97
|
*/
|
|
153
98
|
export function AgentRun(props: AgentRunProps) {
|
|
154
|
-
const {
|
|
155
|
-
const
|
|
156
|
-
const state = props.state ?? (anyRunning ? "streaming" : "done");
|
|
99
|
+
const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
|
|
100
|
+
const segments = toSegments(parts);
|
|
101
|
+
const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
|
|
157
102
|
const streaming = state === "streaming";
|
|
158
|
-
const segments = toSegments(items);
|
|
159
103
|
const lastIndex = segments.length - 1;
|
|
160
104
|
|
|
161
105
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
@@ -167,7 +111,7 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
167
111
|
if (seg.kind === "text") {
|
|
168
112
|
return (
|
|
169
113
|
<View key={seg.id} style={styles.narration}>
|
|
170
|
-
<Markdown>{
|
|
114
|
+
<Markdown>{seg.text}</Markdown>
|
|
171
115
|
</View>
|
|
172
116
|
);
|
|
173
117
|
}
|
|
@@ -186,6 +130,20 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
186
130
|
/>
|
|
187
131
|
);
|
|
188
132
|
})}
|
|
133
|
+
{/* A run-level BREAKING error terminates the feed with a danger row. It lives
|
|
134
|
+
outside `parts` (the stream failed), so the caller passes it explicitly. */}
|
|
135
|
+
{props.error ? (
|
|
136
|
+
<View style={styles.row}>
|
|
137
|
+
<View style={styles.dotCol}>
|
|
138
|
+
<Icon name="circle-alert" size={17} color={colors.red[500]} />
|
|
139
|
+
</View>
|
|
140
|
+
<View style={styles.rowBody}>
|
|
141
|
+
<Text size="sm" color="danger">
|
|
142
|
+
{props.error}
|
|
143
|
+
</Text>
|
|
144
|
+
</View>
|
|
145
|
+
</View>
|
|
146
|
+
) : null}
|
|
189
147
|
</View>
|
|
190
148
|
);
|
|
191
149
|
}
|
|
@@ -213,7 +171,7 @@ function ActivityRow(props: {
|
|
|
213
171
|
);
|
|
214
172
|
if (onPress) {
|
|
215
173
|
return (
|
|
216
|
-
<PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
174
|
+
<PressableHighlight focusRing userSelect="none" onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
217
175
|
{inner}
|
|
218
176
|
</PressableHighlight>
|
|
219
177
|
);
|
|
@@ -221,25 +179,14 @@ function ActivityRow(props: {
|
|
|
221
179
|
return <View style={styles.row}>{inner}</View>;
|
|
222
180
|
}
|
|
223
181
|
|
|
224
|
-
function StepBody({ label
|
|
225
|
-
return
|
|
226
|
-
<View style={{ gap: 1 }}>
|
|
227
|
-
<Text size="sm">{label}</Text>
|
|
228
|
-
{detail ? (
|
|
229
|
-
<Text size="xs" color={status === "error" ? "danger" : "muted"}>
|
|
230
|
-
{detail}
|
|
231
|
-
</Text>
|
|
232
|
-
) : null}
|
|
233
|
-
</View>
|
|
234
|
-
);
|
|
182
|
+
function StepBody({ label }: { label: string }) {
|
|
183
|
+
return <Text size="sm">{label}</Text>;
|
|
235
184
|
}
|
|
236
185
|
|
|
237
|
-
// The
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
|
|
242
|
-
if (s.peek) return s.peek;
|
|
186
|
+
// The on-demand reveal for a tool step: Input / Output / Error panels auto-built
|
|
187
|
+
// from the carried I/O; nothing if there's none. Raw I/O stays OUT of the row — only
|
|
188
|
+
// shown here, on demand. Panel titles come from the locale.
|
|
189
|
+
function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
|
|
243
190
|
const hasInput = s.input !== undefined;
|
|
244
191
|
const hasOutput = s.output !== undefined;
|
|
245
192
|
if (!hasInput && !hasOutput && !s.errorText) return null;
|
|
@@ -255,18 +202,28 @@ function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode
|
|
|
255
202
|
);
|
|
256
203
|
}
|
|
257
204
|
|
|
258
|
-
// A step
|
|
259
|
-
//
|
|
260
|
-
|
|
205
|
+
// A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
|
|
206
|
+
// press — the panels roll out under the row (same disclosure pattern as "Thinking"),
|
|
207
|
+
// never a popover — so an errored call reads like any other step until you open it.
|
|
208
|
+
function StepRow({ s, label }: { s: AgentStep; label: string }) {
|
|
261
209
|
const locale = useLoticsLocale();
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
210
|
+
const [open, setOpen] = useState(false);
|
|
211
|
+
const detail = stepDetail(s, locale.agentRun);
|
|
212
|
+
const marker: StepStatus = s.status === "error" ? "warning" : "done";
|
|
213
|
+
if (!detail) {
|
|
214
|
+
return (
|
|
215
|
+
<ActivityRow markerStatus={marker}>
|
|
216
|
+
<StepBody label={label} />
|
|
217
|
+
</ActivityRow>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
<View>
|
|
222
|
+
<ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
|
|
223
|
+
<StepBody label={label} />
|
|
224
|
+
</ActivityRow>
|
|
225
|
+
{open ? <View style={styles.rowDetail}>{detail}</View> : null}
|
|
226
|
+
</View>
|
|
270
227
|
);
|
|
271
228
|
}
|
|
272
229
|
|
|
@@ -277,7 +234,7 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
|
|
|
277
234
|
const locale = useLoticsLocale();
|
|
278
235
|
return (
|
|
279
236
|
<View style={styles.group}>
|
|
280
|
-
<PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
|
|
237
|
+
<PressableHighlight focusRing userSelect="none" onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
|
|
281
238
|
<View style={styles.dotCol}>
|
|
282
239
|
<Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
|
|
283
240
|
</View>
|
|
@@ -313,7 +270,7 @@ function chevron(dir: "down" | "up") {
|
|
|
313
270
|
}
|
|
314
271
|
|
|
315
272
|
function ToolGroup(props: {
|
|
316
|
-
steps:
|
|
273
|
+
steps: AgentStep[];
|
|
317
274
|
active: boolean;
|
|
318
275
|
expanded: boolean;
|
|
319
276
|
onToggle: () => void;
|
|
@@ -321,7 +278,7 @@ function ToolGroup(props: {
|
|
|
321
278
|
stepsLabel: (n: number) => string;
|
|
322
279
|
}) {
|
|
323
280
|
const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
|
|
324
|
-
const resolve = (s:
|
|
281
|
+
const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
|
|
325
282
|
|
|
326
283
|
// ACTIVE — one pulsing row whose label swaps in place as each call fires (the
|
|
327
284
|
// label is keyed by the current step's id, so a new call rises + fades into the
|
|
@@ -332,7 +289,7 @@ function ToolGroup(props: {
|
|
|
332
289
|
<View style={styles.group}>
|
|
333
290
|
<ActivityRow markerStatus="current" live>
|
|
334
291
|
<AnimationFadeIn key={current.id} translateY={4}>
|
|
335
|
-
<StepBody label={resolve(current)}
|
|
292
|
+
<StepBody label={resolve(current)} />
|
|
336
293
|
</AnimationFadeIn>
|
|
337
294
|
</ActivityRow>
|
|
338
295
|
</View>
|
|
@@ -343,13 +300,11 @@ function ToolGroup(props: {
|
|
|
343
300
|
const final = steps[steps.length - 1];
|
|
344
301
|
const expandable = steps.length > 1;
|
|
345
302
|
|
|
346
|
-
// SETTLED, single call —
|
|
303
|
+
// SETTLED, single call — one done/warning row (its I/O expands in place on press).
|
|
347
304
|
if (!expandable) {
|
|
348
305
|
return (
|
|
349
306
|
<View style={styles.group}>
|
|
350
|
-
<
|
|
351
|
-
<StepContent s={final} label={resolve(final)} />
|
|
352
|
-
</ActivityRow>
|
|
307
|
+
<StepRow s={final} label={resolve(final)} />
|
|
353
308
|
</View>
|
|
354
309
|
);
|
|
355
310
|
}
|
|
@@ -372,9 +327,7 @@ function ToolGroup(props: {
|
|
|
372
327
|
{expanded
|
|
373
328
|
? steps.map((s) => (
|
|
374
329
|
<AnimationFadeIn key={s.id} translateY={4}>
|
|
375
|
-
<
|
|
376
|
-
<StepContent s={s} label={resolve(s)} />
|
|
377
|
-
</ActivityRow>
|
|
330
|
+
<StepRow s={s} label={resolve(s)} />
|
|
378
331
|
</AnimationFadeIn>
|
|
379
332
|
))
|
|
380
333
|
: null}
|
|
@@ -394,9 +347,17 @@ const styles = StyleSheet.create({
|
|
|
394
347
|
gap: 10,
|
|
395
348
|
minHeight: CONTROL_HEIGHT,
|
|
396
349
|
borderRadius: CONTROL_RADIUS,
|
|
350
|
+
// The hover/press highlight BLEEDS 8px past the content on each side (negative
|
|
351
|
+
// margin absorbed by equal padding, the Peek pattern), so its rounded edges
|
|
352
|
+
// never pinch against the leading dot or the trailing chevron. Content stays
|
|
353
|
+
// flush-aligned (net zero), so the run's left edge still meets the consumer's gutter.
|
|
354
|
+
marginHorizontal: -8,
|
|
355
|
+
paddingHorizontal: 8,
|
|
397
356
|
},
|
|
398
357
|
dotCol: { width: 18, alignItems: "center" },
|
|
399
358
|
rowBody: { flex: 1 },
|
|
400
359
|
// Reasoning body aligns under the "Thinking" label (past the dot column + gap).
|
|
401
360
|
reasoning: { paddingLeft: 28, paddingBottom: 4 },
|
|
361
|
+
// A step's expanded I/O panels — same left edge as the reasoning body.
|
|
362
|
+
rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
|
|
402
363
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
2
|
+
|
|
3
|
+
// `@lotics/ui` renders an agent run straight from the ai-sdk `UIMessage.parts` — the
|
|
4
|
+
// canonical, versioned wire shape BOTH the chat and app agents already emit. There
|
|
5
|
+
// is no bespoke transcript type: this module is the one place that folds parts into
|
|
6
|
+
// the render timeline, shared by `AgentRun` and `AgentProgress`. Consumers pass their
|
|
7
|
+
// message's `parts` (chat's static `tool-<name>` parts and the app SDK's
|
|
8
|
+
// `dynamic-tool` parts are both handled).
|
|
9
|
+
|
|
10
|
+
/** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
|
|
11
|
+
export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
|
|
12
|
+
|
|
13
|
+
/** A tool step's settle state, collapsed from ai's 7-state tool machine. */
|
|
14
|
+
export type AgentStepStatus = "running" | "done" | "error";
|
|
15
|
+
|
|
16
|
+
/** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
|
|
17
|
+
* renderer maps it to a human label + icon (`resolveToolMeta`). */
|
|
18
|
+
export interface AgentStep {
|
|
19
|
+
id: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
status: AgentStepStatus;
|
|
22
|
+
/** Revealed ON DEMAND (a press-to-open peek), never inline. */
|
|
23
|
+
input?: unknown;
|
|
24
|
+
output?: unknown;
|
|
25
|
+
errorText?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The render timeline: prose, thinking, and groups of consecutive tool calls, in
|
|
29
|
+
* the order they streamed. */
|
|
30
|
+
export type AgentSegment =
|
|
31
|
+
| { kind: "text"; id: string; text: string }
|
|
32
|
+
| { kind: "reasoning"; id: string; text: string }
|
|
33
|
+
| { kind: "group"; id: string; steps: AgentStep[] };
|
|
34
|
+
|
|
35
|
+
// A tool part — static (`tool-<name>`) or `dynamic-tool` — is exactly the part union
|
|
36
|
+
// that carries `toolCallId`, so extracting on that field is a sound, cast-free guard.
|
|
37
|
+
function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallId: string }> {
|
|
38
|
+
return part.type === "dynamic-tool" || part.type.startsWith("tool-");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ai's tool state machine → the feed's 3 states. Approval-pending/responded read as
|
|
42
|
+
// "running" (a read-only feed has no approval affordance); denied reads as an error.
|
|
43
|
+
function toStatus(state: string): AgentStepStatus {
|
|
44
|
+
if (state === "output-available") return "done";
|
|
45
|
+
if (state === "output-error" || state === "output-denied") return "error";
|
|
46
|
+
return "running";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
|
|
50
|
+
return {
|
|
51
|
+
id: part.toolCallId,
|
|
52
|
+
toolName: part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length),
|
|
53
|
+
status: toStatus(part.state),
|
|
54
|
+
input: part.input,
|
|
55
|
+
output: part.state === "output-available" ? part.output : undefined,
|
|
56
|
+
errorText: part.state === "output-error" ? part.errorText : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold a message's parts into the render timeline. Consecutive tool parts collapse
|
|
62
|
+
* into ONE group (the feed shows a burst as a single expandable row). Prose parts
|
|
63
|
+
* carry no id in ai's model, so a stable index-based one is synthesized. Part kinds
|
|
64
|
+
* with no feed representation (`step-start`, `file`, `source-*`, `data-*`, `custom`)
|
|
65
|
+
* are skipped — the same kinds the chat's own renderer ignores.
|
|
66
|
+
*/
|
|
67
|
+
export function toSegments(parts: readonly AgentUIPart[]): AgentSegment[] {
|
|
68
|
+
const segments: AgentSegment[] = [];
|
|
69
|
+
parts.forEach((part, i) => {
|
|
70
|
+
if (part.type === "text") {
|
|
71
|
+
if (part.text) segments.push({ kind: "text", id: `text-${i}`, text: part.text });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (part.type === "reasoning") {
|
|
75
|
+
if (part.text) segments.push({ kind: "reasoning", id: `reasoning-${i}`, text: part.text });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!isToolPart(part)) return;
|
|
79
|
+
const step = toStep(part);
|
|
80
|
+
const tail = segments[segments.length - 1];
|
|
81
|
+
if (tail?.kind === "group") tail.steps.push(step);
|
|
82
|
+
else segments.push({ kind: "group", id: step.id, steps: [step] });
|
|
83
|
+
});
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether any tool step is still running — the default "is the run streaming" signal. */
|
|
88
|
+
export function anyRunning(segments: readonly AgentSegment[]): boolean {
|
|
89
|
+
return segments.some((s) => s.kind === "group" && s.steps.some((st) => st.status === "running"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The last still-running tool step, if any — for a compact "currently doing X". */
|
|
93
|
+
export function lastRunningStep(segments: readonly AgentSegment[]): AgentStep | undefined {
|
|
94
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
95
|
+
const seg = segments[i];
|
|
96
|
+
if (seg.kind !== "group") continue;
|
|
97
|
+
for (let j = seg.steps.length - 1; j >= 0; j--) {
|
|
98
|
+
if (seg.steps[j].status === "running") return seg.steps[j];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
package/src/json_panel.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Platform, StyleSheet, View } from "react-native";
|
|
1
|
+
import { Platform, ScrollView, StyleSheet, View } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
|
|
@@ -15,18 +15,22 @@ export function JsonPanel(props: JsonPanelProps) {
|
|
|
15
15
|
{title}
|
|
16
16
|
</Text>
|
|
17
17
|
<View style={styles.body}>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
{/* A real dump can be tens of KB (a streamed tool input) — the panel caps
|
|
19
|
+
its height and scrolls inside, so it can never blow a feed open. */}
|
|
20
|
+
<ScrollView style={styles.scroll} contentContainerStyle={styles.content} nestedScrollEnabled>
|
|
21
|
+
<Text
|
|
22
|
+
size="xs"
|
|
23
|
+
style={{
|
|
24
|
+
fontFamily:
|
|
25
|
+
Platform.OS === "web"
|
|
26
|
+
? "ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
27
|
+
: "monospace",
|
|
28
|
+
lineHeight: 18,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
{value}
|
|
32
|
+
</Text>
|
|
33
|
+
</ScrollView>
|
|
30
34
|
</View>
|
|
31
35
|
</View>
|
|
32
36
|
);
|
|
@@ -50,6 +54,14 @@ const styles = StyleSheet.create({
|
|
|
50
54
|
borderColor: colors.zinc[200],
|
|
51
55
|
borderRadius: 8,
|
|
52
56
|
backgroundColor: colors.white,
|
|
57
|
+
// The ScrollView owns the padding (content inset scrolls with the text); the
|
|
58
|
+
// frame just clips so the scrollbar respects the rounded corners.
|
|
59
|
+
overflow: "hidden",
|
|
60
|
+
},
|
|
61
|
+
scroll: {
|
|
62
|
+
maxHeight: 240,
|
|
63
|
+
},
|
|
64
|
+
content: {
|
|
53
65
|
paddingHorizontal: 10,
|
|
54
66
|
paddingVertical: 8,
|
|
55
67
|
},
|
|
@@ -21,18 +21,31 @@ import * as path from "node:path";
|
|
|
21
21
|
* import `./markdown`. So it's allowed — the guards below remain for the
|
|
22
22
|
* genuine context couplings.
|
|
23
23
|
*
|
|
24
|
+
* The ONE allowed `ai` coupling: the `AgentRun` family renders the ai-sdk
|
|
25
|
+
* `UIMessage.parts` shape directly — that IS its contract (both chat and app
|
|
26
|
+
* agents emit it), so there is no bespoke transcript type to keep in sync.
|
|
27
|
+
* `agent_transform.ts` (the single place that folds parts into the render
|
|
28
|
+
* timeline) `import type`s from `ai` — erased at runtime, so no `ai` enters any
|
|
29
|
+
* consumer's bundle. That file alone is exempt from the `ai`-package guard; every
|
|
30
|
+
* other primitive stays ai-free, and the `@ai-sdk/*` runtime packages are never
|
|
31
|
+
* allowed anywhere.
|
|
32
|
+
*
|
|
24
33
|
* The boundary is enforced by this test — without it primitives drift
|
|
25
34
|
* back into the parent app's contexts over months.
|
|
26
35
|
*/
|
|
27
36
|
|
|
37
|
+
const AI_PACKAGE_PATTERN = /from\s+["']ai["']/;
|
|
28
38
|
const FORBIDDEN_PATTERNS = [
|
|
29
39
|
/from\s+["']posthog-js/,
|
|
30
40
|
/from\s+["']@lingui\//,
|
|
31
41
|
/from\s+["']@ai-sdk\//,
|
|
32
|
-
|
|
42
|
+
AI_PACKAGE_PATTERN,
|
|
33
43
|
/from\s+["']@lotics\/shared/,
|
|
34
44
|
];
|
|
35
45
|
|
|
46
|
+
// The sole file permitted to `import type` from the `ai` package (see above).
|
|
47
|
+
const AI_PACKAGE_ALLOWED = new Set(["agent_transform.ts"]);
|
|
48
|
+
|
|
36
49
|
const SRC_DIR = path.resolve(__dirname);
|
|
37
50
|
|
|
38
51
|
function listTopLevelSourceFiles(): string[] {
|
|
@@ -49,6 +62,7 @@ describe("packages/ui primitives — purity", () => {
|
|
|
49
62
|
for (const file of listTopLevelSourceFiles()) {
|
|
50
63
|
const src = fs.readFileSync(file, "utf-8");
|
|
51
64
|
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
65
|
+
if (pattern === AI_PACKAGE_PATTERN && AI_PACKAGE_ALLOWED.has(path.basename(file))) continue;
|
|
52
66
|
if (pattern.test(src)) {
|
|
53
67
|
violations.push(`${path.basename(file)} matches ${pattern}`);
|
|
54
68
|
}
|