@lotics/ui 5.4.0 → 5.6.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 +26 -14
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_lookup.tsx +1 -1
- package/examples/tpl_pick.tsx +22 -14
- package/examples/tpl_ratedesk.tsx +1 -1
- package/package.json +1 -2
- package/src/agent_progress.tsx +14 -4
- package/src/agent_run.tsx +128 -133
- package/src/animation_fade_in.tsx +12 -5
- package/src/share_or_download.test.ts +54 -1
- package/src/share_or_download.ts +56 -2
- package/src/stepper.tsx +206 -65
- package/src/step_list.tsx +0 -128
package/AGENTS.md
CHANGED
|
@@ -75,9 +75,10 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
75
75
|
- **Files** — `FileDropzone`, `FileThumbnail` / `FileThumbnailGrid`, `FilePreview` /
|
|
76
76
|
`FileGalleryModal`, `ImageGallery`; for gated CRUD compose locally with `useSelectionMode` +
|
|
77
77
|
`shareOrDownloadFiles` + `rotateImageToBlob` (see §Data entry → Attachments).
|
|
78
|
-
- **Specialized work surfaces** — `ScanField` (scan/verify), `
|
|
79
|
-
|
|
80
|
-
`
|
|
78
|
+
- **Specialized work surfaces** — `ScanField` (scan/verify), `Stepper` (a guided run / progress
|
|
79
|
+
sequence — done · current · upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow`
|
|
80
|
+
(allocation), `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
|
|
81
|
+
`Calendar`, `Gantt`, `comments_thread`.
|
|
81
82
|
- **AI surfaces** — `Composer` (the adaptive command/chat composer — a compact pill when empty that
|
|
82
83
|
expands for long text + attachments; the surface that triggers agent work),
|
|
83
84
|
`AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
|
|
@@ -221,10 +222,15 @@ never removes a file. Three reusable primitives back it (the logic is shared; th
|
|
|
221
222
|
copy stay local):
|
|
222
223
|
- **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit, toggle,
|
|
223
224
|
toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's `selectedIds`).
|
|
224
|
-
- **`shareOrDownloadFiles(files, { title })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
|
|
225
|
+
- **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
|
|
225
226
|
(mobile → Save to gallery / send to an app), else individual `downloadFileFromUrl` — **never a ZIP**.
|
|
226
|
-
It shares the BYTES (fetches each
|
|
227
|
-
|
|
227
|
+
It shares the BYTES (fetches each URL → `File`), so URL expiry afterward is moot; the share path needs the
|
|
228
|
+
host iframe to grant `allow="web-share"` (else it falls back to download). Best for FAST urls (presigned
|
|
229
|
+
R2). **For SLOW urls (auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
|
|
230
|
+
{credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])` runs
|
|
231
|
+
`navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch burns the
|
|
232
|
+
tap's transient activation**, which silently lands in the download fallback. `prepareShareFiles` throws on
|
|
233
|
+
a fetch failure (log it, don't swallow); `shareFiles` returns `"shared"|"cancelled"|"unsupported"`.
|
|
228
234
|
- **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation into a
|
|
229
235
|
NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is persisted). Pair with
|
|
230
236
|
`FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a rotated image).
|
|
@@ -264,9 +270,13 @@ Compose the surfaces as a loop, and reach for the right one by job:
|
|
|
264
270
|
attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
|
|
265
271
|
way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
|
|
266
272
|
a morph).
|
|
267
|
-
- **Show the work** — `AgentRun`: a live feed of the agent's
|
|
268
|
-
|
|
269
|
-
|
|
273
|
+
- **Show the work** — `AgentRun`: a live feed of the agent's work — its streamed narration (with a
|
|
274
|
+
blinking caret) + tool calls as a vertical `Stepper` of neutral dots (the latest pulsing while live,
|
|
275
|
+
a white-check dot once done). No title, no avatar — just the work. A terminal "Done" step (the
|
|
276
|
+
`complete` node) appears ONLY once the run finishes. A step can carry `peek` content to glance into
|
|
277
|
+
via a popover. Tool labels resolve from the raw tool name via a built-in default map + an optional
|
|
278
|
+
`labelForTool` override (localize THERE — the kit stays English). `doneLabel` localizes the terminal.
|
|
279
|
+
Transparent work, NEVER a bare spinner — the
|
|
270
280
|
intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
|
|
271
281
|
collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
|
|
272
282
|
morphs into it while running, and reveals again when done.
|
|
@@ -365,9 +375,11 @@ controls — while **status/severity/diffs use functional colour** the way the r
|
|
|
365
375
|
- **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
|
|
366
376
|
- **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
|
|
367
377
|
emerald on-track (+ stacking order, most severe first).
|
|
368
|
-
- **AgentRun** —
|
|
369
|
-
|
|
370
|
-
|
|
378
|
+
- **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
|
|
379
|
+
centre), `done` a filled accent dot + **white check**, `upcoming` a faint **grey** ring, the terminal
|
|
380
|
+
`complete` a **blackish ring + black check**, `warning` **amber**. Default accent is neutral ink
|
|
381
|
+
(`color` themes it); `AgentRun` keeps the neutral default + appends the `complete` "Done" node only
|
|
382
|
+
when the run finishes.
|
|
371
383
|
- **ChangeReview diffs** — the old value struck in **red**, the new value in **green**.
|
|
372
384
|
- **Discrepancy** — the agent's pick gets a **blue** highlight; `Mismatch` reads amber, `Resolved`
|
|
373
385
|
emerald.
|
|
@@ -584,8 +596,8 @@ inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s
|
|
|
584
596
|
list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
|
|
585
597
|
floating_action_bar · filter_pill · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
586
598
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
587
|
-
sort_header · table · pagination · accordion · stepper ·
|
|
588
|
-
step_progress ·
|
|
599
|
+
sort_header · table · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
600
|
+
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
|
|
589
601
|
command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
|
|
590
602
|
CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
|
|
591
603
|
bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
|
package/examples/tpl_extract.tsx
CHANGED
|
@@ -133,7 +133,7 @@ export function TplExtract() {
|
|
|
133
133
|
|
|
134
134
|
{phase === "extracting" ? (
|
|
135
135
|
<Card>
|
|
136
|
-
<AgentRun
|
|
136
|
+
<AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
|
|
137
137
|
</Card>
|
|
138
138
|
) : null}
|
|
139
139
|
|
package/examples/tpl_lookup.tsx
CHANGED
|
@@ -201,7 +201,7 @@ export function TplLookup() {
|
|
|
201
201
|
|
|
202
202
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
203
203
|
{/* current run */}
|
|
204
|
-
<AgentRun
|
|
204
|
+
<AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
|
|
205
205
|
|
|
206
206
|
<View style={{ gap: 8 }}>
|
|
207
207
|
<Text size="xs" color="muted" weight="medium">Nearest matches</Text>
|
package/examples/tpl_pick.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { Divider } from "@lotics/ui/divider";
|
|
|
11
11
|
import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
|
|
12
12
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
13
13
|
import { ScanField } from "@lotics/ui/scan_field";
|
|
14
|
-
import {
|
|
14
|
+
import { Stepper, Step, type StepStatus } from "@lotics/ui/stepper";
|
|
15
15
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
16
16
|
|
|
17
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -155,18 +155,6 @@ function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }
|
|
|
155
155
|
// The right rail — the whole run at a glance: done · now · up next. Read-only;
|
|
156
156
|
// the run is sequential, so position is shown, not chosen.
|
|
157
157
|
function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
|
|
158
|
-
const steps: StepListItem[] = lines.map((l, i) => ({
|
|
159
|
-
id: l.id,
|
|
160
|
-
status: l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "pending",
|
|
161
|
-
title: l.bin,
|
|
162
|
-
subtitle: l.item,
|
|
163
|
-
trailing:
|
|
164
|
-
l.status === "short" ? (
|
|
165
|
-
<Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
|
|
166
|
-
) : (
|
|
167
|
-
<Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
|
|
168
|
-
),
|
|
169
|
-
}));
|
|
170
158
|
return (
|
|
171
159
|
<Card style={{ padding: 0 }}>
|
|
172
160
|
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
|
@@ -174,7 +162,27 @@ function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: num
|
|
|
174
162
|
</View>
|
|
175
163
|
<Divider />
|
|
176
164
|
<View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
|
|
177
|
-
<
|
|
165
|
+
<Stepper orientation="vertical" accessibilityLabel="Pick path">
|
|
166
|
+
{lines.map((l, i) => {
|
|
167
|
+
const status: StepStatus =
|
|
168
|
+
l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "upcoming";
|
|
169
|
+
return (
|
|
170
|
+
<Step key={l.id} status={status}>
|
|
171
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 }}>
|
|
172
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
173
|
+
<Text size="sm">{l.bin}</Text>
|
|
174
|
+
<Text size="xs" color="muted" numberOfLines={1}>{l.item}</Text>
|
|
175
|
+
</View>
|
|
176
|
+
{l.status === "short" ? (
|
|
177
|
+
<Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
|
|
178
|
+
) : (
|
|
179
|
+
<Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
|
|
180
|
+
)}
|
|
181
|
+
</View>
|
|
182
|
+
</Step>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</Stepper>
|
|
178
186
|
</View>
|
|
179
187
|
</Card>
|
|
180
188
|
);
|
|
@@ -238,7 +238,7 @@ export function TplRatedesk() {
|
|
|
238
238
|
</View>
|
|
239
239
|
) : null}
|
|
240
240
|
|
|
241
|
-
{phase === "reading" ? <AgentRun
|
|
241
|
+
{phase === "reading" ? <AgentRun steps={runSteps} state="streaming" /> : null}
|
|
242
242
|
|
|
243
243
|
{phase === "review" ? (
|
|
244
244
|
<ChangeReview
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -139,7 +139,6 @@
|
|
|
139
139
|
"./table": "./src/table.tsx",
|
|
140
140
|
"./detail_row": "./src/detail_row.tsx",
|
|
141
141
|
"./scan_field": "./src/scan_field.tsx",
|
|
142
|
-
"./step_list": "./src/step_list.tsx",
|
|
143
142
|
"./completion_state": "./src/completion_state.tsx",
|
|
144
143
|
"./remainder_meter": "./src/remainder_meter.tsx",
|
|
145
144
|
"./allocation_row": "./src/allocation_row.tsx",
|
package/src/agent_progress.tsx
CHANGED
|
@@ -4,7 +4,7 @@ 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, type AgentRunStep } from "./agent_run";
|
|
7
|
+
import { AgentRun, resolveToolMeta, type AgentRunStep } from "./agent_run";
|
|
8
8
|
|
|
9
9
|
export interface AgentProgressProps {
|
|
10
10
|
steps: AgentRunStep[];
|
|
@@ -12,6 +12,11 @@ export interface AgentProgressProps {
|
|
|
12
12
|
/** The collapsed-pill label. Defaults to the running step's label (or
|
|
13
13
|
* "Working…" / "Done"). */
|
|
14
14
|
label?: string;
|
|
15
|
+
/** The agent's streamed text — forwarded to the expanded `AgentRun`. */
|
|
16
|
+
text?: string;
|
|
17
|
+
/** Localize / override a tool step's label by raw tool name — applied to both
|
|
18
|
+
* the collapsed pill and the expanded feed. */
|
|
19
|
+
labelForTool?: (toolName: string) => string | undefined;
|
|
15
20
|
defaultExpanded?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -24,11 +29,16 @@ export interface AgentProgressProps {
|
|
|
24
29
|
* label, so the surface stays calm until you ask to see the steps.
|
|
25
30
|
*/
|
|
26
31
|
export function AgentProgress(props: AgentProgressProps) {
|
|
27
|
-
const { steps, state = "streaming", label, defaultExpanded } = props;
|
|
32
|
+
const { steps, state = "streaming", label, text, labelForTool, defaultExpanded } = props;
|
|
28
33
|
const [expanded, setExpanded] = useState(defaultExpanded ?? false);
|
|
29
34
|
|
|
30
35
|
const running = steps.filter((s) => s.status === "running").at(-1);
|
|
31
|
-
const
|
|
36
|
+
const runningLabel = running
|
|
37
|
+
? running.kind === "tool"
|
|
38
|
+
? resolveToolMeta(running.label, labelForTool).label
|
|
39
|
+
: running.label
|
|
40
|
+
: undefined;
|
|
41
|
+
const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
|
|
32
42
|
const streaming = state === "streaming";
|
|
33
43
|
|
|
34
44
|
return (
|
|
@@ -36,7 +46,7 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
36
46
|
{expanded ? (
|
|
37
47
|
<View style={styles.panel}>
|
|
38
48
|
<ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
|
|
39
|
-
<AgentRun steps={steps} state={state} />
|
|
49
|
+
<AgentRun steps={steps} state={state} text={text} labelForTool={labelForTool} />
|
|
40
50
|
</ScrollView>
|
|
41
51
|
</View>
|
|
42
52
|
) : null}
|
package/src/agent_run.tsx
CHANGED
|
@@ -1,174 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { Animated, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors, solid } from "./colors";
|
|
3
4
|
import { Text } from "./text";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { type IconName } from "./icon";
|
|
6
|
+
import { Peek } from "./peek";
|
|
7
|
+
import { Stepper, Step, type StepStatus } from "./stepper";
|
|
7
8
|
|
|
8
9
|
export type AgentStepStatus = "running" | "done" | "error";
|
|
9
10
|
|
|
10
11
|
export interface AgentRunStep {
|
|
11
12
|
id: string;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
13
|
+
/** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
|
|
14
|
+
* it to a human label via the default map / `labelForTool`. A `step` carries
|
|
15
|
+
* an already-human label. */
|
|
14
16
|
label: string;
|
|
15
|
-
/**
|
|
16
|
-
* model streams; the host appends to it. */
|
|
17
|
+
/** A note under the label (a count, an input summary), muted. */
|
|
17
18
|
detail?: string;
|
|
18
19
|
status: AgentStepStatus;
|
|
19
|
-
/** A `tool` step renders its label in a monospace chip — it called a tool
|
|
20
|
-
* rather than reasoned. */
|
|
21
20
|
kind?: "step" | "tool";
|
|
21
|
+
/** Optional content shown in a `Peek` popover when the step is pressed —
|
|
22
|
+
* glance into what the step did (the tool input, a result summary). */
|
|
23
|
+
peek?: ReactNode;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface AgentRunProps {
|
|
25
27
|
steps: AgentRunStep[];
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
28
|
+
/** The agent's streamed reasoning / answer text — shown as a narration block
|
|
29
|
+
* above the feed, with a live caret while streaming. */
|
|
30
|
+
text?: string;
|
|
31
|
+
/** Whole-run state. `streaming` keeps the caret blinking + the latest step
|
|
32
|
+
* pulsing; `done`/`error` settle it. Defaults to `streaming` while a step runs. */
|
|
29
33
|
state?: "streaming" | "done" | "error";
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
|
|
34
|
+
/** Localize / override a TOOL step's display label by its raw tool name
|
|
35
|
+
* (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
|
|
36
|
+
* back to the built-in label. */
|
|
37
|
+
labelForTool?: (toolName: string) => string | undefined;
|
|
38
|
+
/** The always-last terminal step's label. Default "Done"; localize per app. */
|
|
39
|
+
doneLabel?: string;
|
|
33
40
|
accessibilityLabel?: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// ── Tool → display meta ───────────────────────────────────────────────────────
|
|
44
|
+
// The bounded set of platform tools an app agent can call. English by default
|
|
45
|
+
// (the kit is locale-neutral; apps localize via `labelForTool`). A tool the map
|
|
46
|
+
// doesn't know falls back to a prettified name + a neutral icon.
|
|
47
|
+
const TOOL_META: Record<string, { label: string; icon: IconName }> = {
|
|
48
|
+
query_records: { label: "Searching records", icon: "search" },
|
|
49
|
+
get_record: { label: "Reading a record", icon: "file-text" },
|
|
50
|
+
get_records: { label: "Reading records", icon: "file-text" },
|
|
51
|
+
create_records: { label: "Creating records", icon: "plus" },
|
|
52
|
+
update_records: { label: "Updating records", icon: "square-pen" },
|
|
53
|
+
delete_records: { label: "Removing records", icon: "trash" },
|
|
54
|
+
generate_pdf_from_template: { label: "Generating PDF", icon: "file-down" },
|
|
55
|
+
generate_excel_from_template: { label: "Generating spreadsheet", icon: "file-spreadsheet" },
|
|
56
|
+
generate_docx_from_template: { label: "Generating document", icon: "file-text" },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function prettifyToolName(name: string): string {
|
|
60
|
+
const s = name.replace(/_/g, " ").trim();
|
|
61
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a tool's display label + icon: a caller override wins on the label,
|
|
65
|
+
* then the built-in map, then a prettified fallback. Exported so other agent
|
|
66
|
+
* surfaces (e.g. `AgentProgress`) render the same labels. */
|
|
67
|
+
export function resolveToolMeta(
|
|
68
|
+
toolName: string,
|
|
69
|
+
labelForTool?: (toolName: string) => string | undefined,
|
|
70
|
+
): { label: string; icon: IconName } {
|
|
71
|
+
const def =
|
|
72
|
+
TOOL_META[toolName] ??
|
|
73
|
+
(toolName.startsWith("generate_") && toolName.endsWith("_from_template")
|
|
74
|
+
? { label: "Generating document", icon: "file-text" as IconName }
|
|
75
|
+
: undefined);
|
|
76
|
+
return {
|
|
77
|
+
label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
|
|
78
|
+
icon: def?.icon ?? "list",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
/**
|
|
37
|
-
* A live feed of an AI agent's work — the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* run) and `ReviewCard` (the result to review). Unlike `StepList` (a known,
|
|
44
|
-
* guided run of fixed steps) the steps here arrive unknown-ahead.
|
|
83
|
+
* A live feed of an AI agent's work — the prose it streams + the tools it calls,
|
|
84
|
+
* on a vertical `Stepper`: one neutral dot per call (the latest pulsing while the
|
|
85
|
+
* run is live, a check once done) with a plain-text label, no chip/icon, ALWAYS
|
|
86
|
+
* ending on a "Done" terminal step. A step can carry `peek` content to glance
|
|
87
|
+
* into via a popover. Tool names map to labels via the built-in default + a
|
|
88
|
+
* `labelForTool` override. Pair with `Composer` + `ReviewCard`.
|
|
45
89
|
*/
|
|
46
90
|
export function AgentRun(props: AgentRunProps) {
|
|
47
|
-
const { steps,
|
|
91
|
+
const { steps, text, labelForTool, doneLabel = "Done", accessibilityLabel } = props;
|
|
48
92
|
const anyRunning = steps.some((s) => s.status === "running");
|
|
49
93
|
const state = props.state ?? (anyRunning ? "streaming" : "done");
|
|
50
94
|
const streaming = state === "streaming";
|
|
95
|
+
const last = steps.length - 1;
|
|
51
96
|
|
|
52
97
|
return (
|
|
53
|
-
<View accessibilityLabel={accessibilityLabel} style={{ gap:
|
|
54
|
-
{
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<HeaderStatus state={state} streaming={streaming} />
|
|
60
|
-
</View>
|
|
98
|
+
<View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
|
|
99
|
+
{text ? (
|
|
100
|
+
<Text size="sm" style={styles.narration}>
|
|
101
|
+
{text}
|
|
102
|
+
{streaming ? <Caret /> : null}
|
|
103
|
+
</Text>
|
|
61
104
|
) : null}
|
|
62
105
|
|
|
63
|
-
<
|
|
106
|
+
<Stepper orientation="vertical" live={streaming}>
|
|
64
107
|
{steps.map((s, i) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<Text
|
|
83
|
-
size="sm"
|
|
84
|
-
weight={s.status === "running" ? "medium" : "regular"}
|
|
85
|
-
color={s.status === "done" ? "muted" : s.status === "error" ? "danger" : "default"}
|
|
86
|
-
>
|
|
87
|
-
{s.label}
|
|
88
|
-
</Text>
|
|
89
|
-
)}
|
|
90
|
-
{s.detail ? (
|
|
91
|
-
<Text size="xs" color="muted" style={{ marginTop: 2 }}>
|
|
92
|
-
{s.detail}
|
|
93
|
-
</Text>
|
|
94
|
-
) : null}
|
|
95
|
-
</View>
|
|
108
|
+
// Tools the SDK feeds arrive already "done"; show the most recent as
|
|
109
|
+
// the pulsing current while the run is still live.
|
|
110
|
+
const status: StepStatus =
|
|
111
|
+
s.status === "error"
|
|
112
|
+
? "warning"
|
|
113
|
+
: s.status === "running" || (streaming && i === last)
|
|
114
|
+
? "current"
|
|
115
|
+
: "done";
|
|
116
|
+
const label = s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
|
|
117
|
+
const body = (
|
|
118
|
+
<View style={{ gap: 1 }}>
|
|
119
|
+
<Text size="sm">{label}</Text>
|
|
120
|
+
{s.detail ? (
|
|
121
|
+
<Text size="xs" color={s.status === "error" ? "danger" : "muted"}>
|
|
122
|
+
{s.detail}
|
|
123
|
+
</Text>
|
|
124
|
+
) : null}
|
|
96
125
|
</View>
|
|
97
126
|
);
|
|
127
|
+
return (
|
|
128
|
+
<Step key={s.id} status={status}>
|
|
129
|
+
{s.peek ? (
|
|
130
|
+
<Peek accessibilityLabel={label} content={s.peek}>
|
|
131
|
+
{body}
|
|
132
|
+
</Peek>
|
|
133
|
+
) : (
|
|
134
|
+
body
|
|
135
|
+
)}
|
|
136
|
+
</Step>
|
|
137
|
+
);
|
|
98
138
|
})}
|
|
99
|
-
|
|
139
|
+
{state === "done" ? (
|
|
140
|
+
<Step key="__done" status="complete">
|
|
141
|
+
<Text size="sm" weight="medium">
|
|
142
|
+
{doneLabel}
|
|
143
|
+
</Text>
|
|
144
|
+
</Step>
|
|
145
|
+
) : null}
|
|
146
|
+
</Stepper>
|
|
100
147
|
</View>
|
|
101
148
|
);
|
|
102
149
|
}
|
|
103
150
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<ActivityIndicator size={12} color={solid("blue")} />
|
|
114
|
-
</View>
|
|
151
|
+
// A blinking caret that trails the streamed text — the signature of live writing.
|
|
152
|
+
function Caret() {
|
|
153
|
+
const opacity = useRef(new Animated.Value(1)).current;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const loop = Animated.loop(
|
|
156
|
+
Animated.sequence([
|
|
157
|
+
Animated.timing(opacity, { toValue: 0, duration: 480, useNativeDriver: false }),
|
|
158
|
+
Animated.timing(opacity, { toValue: 1, duration: 480, useNativeDriver: false }),
|
|
159
|
+
]),
|
|
115
160
|
);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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) {
|
|
127
|
-
return (
|
|
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>
|
|
133
|
-
</View>
|
|
134
|
-
);
|
|
135
|
-
}
|
|
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
|
-
);
|
|
145
|
-
}
|
|
146
|
-
return (
|
|
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>
|
|
152
|
-
</View>
|
|
153
|
-
);
|
|
161
|
+
loop.start();
|
|
162
|
+
return () => loop.stop();
|
|
163
|
+
}, [opacity]);
|
|
164
|
+
return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
|
-
const NODE = 22;
|
|
157
|
-
|
|
158
167
|
const styles = StyleSheet.create({
|
|
159
|
-
|
|
160
|
-
headerStatus: { flexDirection: "row", alignItems: "center", gap: 5 },
|
|
161
|
-
item: { flexDirection: "row", gap: 12 },
|
|
162
|
-
spineCol: { width: NODE, alignItems: "center", paddingTop: 1 },
|
|
163
|
-
spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
|
|
164
|
-
contentCol: { flex: 1, paddingTop: 2 },
|
|
165
|
-
contentGap: { paddingBottom: 14 },
|
|
166
|
-
node: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
167
|
-
toolChip: {
|
|
168
|
-
alignSelf: "flex-start",
|
|
169
|
-
paddingHorizontal: 8,
|
|
170
|
-
paddingVertical: 3,
|
|
171
|
-
borderRadius: 6,
|
|
172
|
-
backgroundColor: colors.zinc[100],
|
|
173
|
-
},
|
|
168
|
+
narration: { lineHeight: 21, color: colors.zinc[700] },
|
|
174
169
|
});
|
|
@@ -4,19 +4,26 @@ import { Animated, StyleProp, ViewStyle } from "react-native";
|
|
|
4
4
|
interface AnimationFadeInProps {
|
|
5
5
|
children: React.ReactNode;
|
|
6
6
|
style?: StyleProp<ViewStyle>;
|
|
7
|
+
/** Rise this many px while fading in — the "appearing into place" gesture.
|
|
8
|
+
* Default 0 (fade only). */
|
|
9
|
+
translateY?: number;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export function AnimationFadeIn(props: AnimationFadeInProps) {
|
|
10
|
-
const { children, style } = props;
|
|
11
|
-
const
|
|
13
|
+
const { children, style, translateY = 0 } = props;
|
|
14
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
12
15
|
|
|
13
16
|
useEffect(() => {
|
|
14
|
-
Animated.spring(
|
|
17
|
+
Animated.spring(progress, {
|
|
15
18
|
toValue: 1,
|
|
16
19
|
bounciness: 0,
|
|
17
20
|
useNativeDriver: true,
|
|
18
21
|
}).start();
|
|
19
|
-
}, [
|
|
22
|
+
}, [progress]);
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
const transform = translateY
|
|
25
|
+
? [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [translateY, 0] }) }]
|
|
26
|
+
: undefined;
|
|
27
|
+
|
|
28
|
+
return <Animated.View style={[{ opacity: progress, transform }, style]}>{children}</Animated.View>;
|
|
22
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { shareOrDownloadFiles } from "./share_or_download";
|
|
2
|
+
import { shareOrDownloadFiles, prepareShareFiles, shareFiles } from "./share_or_download";
|
|
3
3
|
|
|
4
4
|
afterEach(() => {
|
|
5
5
|
vi.restoreAllMocks();
|
|
@@ -29,6 +29,17 @@ describe("shareOrDownloadFiles", () => {
|
|
|
29
29
|
expect(shared.title).toBe("Ảnh");
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
|
|
33
|
+
// Type the init param so mock.calls[0][1] is the RequestInit we assert on.
|
|
34
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
35
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
36
|
+
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
|
|
37
|
+
|
|
38
|
+
await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
39
|
+
|
|
40
|
+
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
41
|
+
});
|
|
42
|
+
|
|
32
43
|
it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
|
|
33
44
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
34
45
|
const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
|
|
@@ -37,3 +48,45 @@ describe("shareOrDownloadFiles", () => {
|
|
|
37
48
|
expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
|
|
38
49
|
});
|
|
39
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("prepareShareFiles + shareFiles (iOS: fetch ahead of the gesture, share synchronously)", () => {
|
|
53
|
+
it("prepareShareFiles fetches the bytes into File[] with the given credentials", async () => {
|
|
54
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
55
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
56
|
+
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
57
|
+
|
|
58
|
+
const files = await prepareShareFiles([{ url: "https://api/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
59
|
+
|
|
60
|
+
expect(files).not.toBeNull();
|
|
61
|
+
expect(files?.[0]).toBeInstanceOf(File);
|
|
62
|
+
expect(files?.[0].name).toBe("a.png");
|
|
63
|
+
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("prepareShareFiles returns null when Web Share (files) is unsupported", async () => {
|
|
67
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"]))));
|
|
68
|
+
vi.stubGlobal("navigator", {}); // no canShare / share
|
|
69
|
+
expect(await prepareShareFiles([{ url: "u", filename: "a.png" }])).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("prepareShareFiles THROWS on a fetch failure (caller logs it, not a silent download)", async () => {
|
|
73
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
|
|
74
|
+
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
75
|
+
await expect(prepareShareFiles([{ url: "u", filename: "a.png" }])).rejects.toThrow(/401/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("shareFiles shares the SAME pre-fetched File (no re-fetch) and maps cancel/unsupported", async () => {
|
|
79
|
+
const share = vi.fn(async (_d: { files: File[] }) => undefined);
|
|
80
|
+
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
81
|
+
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
82
|
+
|
|
83
|
+
expect(await shareFiles([file], { title: "T" })).toBe("shared");
|
|
84
|
+
expect(share.mock.calls[0][0].files[0]).toBe(file); // the exact pre-fetched File crosses the boundary
|
|
85
|
+
|
|
86
|
+
vi.stubGlobal("navigator", { share: vi.fn(async () => { throw new DOMException("x", "AbortError"); }) });
|
|
87
|
+
expect(await shareFiles([file])).toBe("cancelled");
|
|
88
|
+
|
|
89
|
+
vi.stubGlobal("navigator", {}); // no share
|
|
90
|
+
expect(await shareFiles([file])).toBe("unsupported");
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/share_or_download.ts
CHANGED
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Web Share inside an iframe requires the host to grant `allow="web-share"` on
|
|
7
7
|
// the iframe element; without it `navigator.canShare({ files })` is false and we
|
|
8
|
-
// fall through to downloads.
|
|
9
|
-
//
|
|
8
|
+
// fall through to downloads.
|
|
9
|
+
//
|
|
10
|
+
// iOS Safari quirk: `navigator.share` MUST be called within the tap's *transient
|
|
11
|
+
// activation*. Awaiting a SLOW blob fetch before it (e.g. an auth-gated proxy URL
|
|
12
|
+
// with a server-side R2 round-trip) burns the activation → `share()` rejects →
|
|
13
|
+
// the caller falls back to a download. So for slow URLs, fetch the File[] BEFORE
|
|
14
|
+
// the gesture with `prepareShareFiles`, then call `shareFiles` synchronously on
|
|
15
|
+
// the tap. `shareOrDownloadFiles` is the all-in-one convenience for FAST URLs
|
|
16
|
+
// (presigned R2) where the fetch fits inside the activation window.
|
|
10
17
|
//
|
|
11
18
|
// Pure web, no React Native / @lotics/shared imports — consumable by both the
|
|
12
19
|
// host frontend and sandboxed custom-code apps via the per-file export.
|
|
@@ -23,6 +30,10 @@ export type ShareOrDownloadResult =
|
|
|
23
30
|
| { delivered: "share"; count: number }
|
|
24
31
|
| { delivered: "download"; count: number };
|
|
25
32
|
|
|
33
|
+
/** "shared" = the sheet completed · "cancelled" = the user dismissed it ·
|
|
34
|
+
* "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
|
|
35
|
+
export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
|
|
36
|
+
|
|
26
37
|
async function toFile(f: ShareableFile, credentials: RequestCredentials): Promise<File> {
|
|
27
38
|
const res = await fetch(f.url, { cache: "no-store", credentials });
|
|
28
39
|
if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
|
|
@@ -30,6 +41,49 @@ async function toFile(f: ShareableFile, credentials: RequestCredentials): Promis
|
|
|
30
41
|
return new File([blob], f.filename, { type: f.mimeType || blob.type || "application/octet-stream" });
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Fetch `files` into File objects ready for {@link shareFiles}, to be done BEFORE
|
|
46
|
+
* the share gesture so the share call itself stays synchronous (the iOS note
|
|
47
|
+
* above). Returns the File[] when Web Share (files) is supported and the set is
|
|
48
|
+
* shareable; returns `null` when Web Share is unavailable or the set isn't
|
|
49
|
+
* shareable (the caller should download instead). THROWS if a fetch fails, so the
|
|
50
|
+
* caller can log the real reason rather than silently downloading.
|
|
51
|
+
*/
|
|
52
|
+
export async function prepareShareFiles(
|
|
53
|
+
files: ShareableFile[],
|
|
54
|
+
opts?: { credentials?: RequestCredentials },
|
|
55
|
+
): Promise<File[] | null> {
|
|
56
|
+
const valid = files.filter((f) => f.url);
|
|
57
|
+
if (valid.length === 0) return null;
|
|
58
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
59
|
+
if (!nav || typeof nav.canShare !== "function" || typeof nav.share !== "function") return null;
|
|
60
|
+
const fileObjs = await Promise.all(valid.map((f) => toFile(f, opts?.credentials ?? "same-origin")));
|
|
61
|
+
if (fileObjs.length === 0 || !nav.canShare({ files: fileObjs })) return null;
|
|
62
|
+
return fileObjs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
|
|
67
|
+
* user gesture (no awaited fetch before it) so iOS keeps the transient activation.
|
|
68
|
+
* "cancelled" = user dismissed · "unsupported" = no Web Share; a real failure THROWS.
|
|
69
|
+
*/
|
|
70
|
+
export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
|
|
71
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
72
|
+
if (!nav || typeof nav.share !== "function" || fileObjs.length === 0) return "unsupported";
|
|
73
|
+
try {
|
|
74
|
+
await nav.share({ files: fileObjs, title: opts?.title, text: opts?.text });
|
|
75
|
+
return "shared";
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err instanceof DOMException && err.name === "AbortError") return "cancelled";
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* All-in-one: fetch + share, else download each file (never a ZIP). Best for FAST
|
|
84
|
+
* URLs (presigned R2) where the fetch fits inside the iOS activation window; for
|
|
85
|
+
* slow URLs, prepare ahead with {@link prepareShareFiles} + {@link shareFiles}.
|
|
86
|
+
*/
|
|
33
87
|
export async function shareOrDownloadFiles(
|
|
34
88
|
files: ShareableFile[],
|
|
35
89
|
opts?: { title?: string; text?: string; credentials?: RequestCredentials },
|
package/src/stepper.tsx
CHANGED
|
@@ -1,88 +1,229 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
createContext,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { Animated, StyleSheet, View } from "react-native";
|
|
13
|
+
import { colors, solid } from "./colors";
|
|
3
14
|
import { Icon } from "./icon";
|
|
4
15
|
import { Text } from "./text";
|
|
16
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
17
|
+
import { AnimationFadeIn } from "./animation_fade_in";
|
|
5
18
|
|
|
6
|
-
|
|
19
|
+
// A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
|
|
20
|
+
// where we are (ring + white centre, pulses when live); `done` = passed (filled);
|
|
21
|
+
// `complete` = the terminal "finished" marker (filled + check); `warning` = an
|
|
22
|
+
// issue at a reached step.
|
|
23
|
+
export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
|
|
24
|
+
export type StepOrientation = "horizontal" | "vertical";
|
|
25
|
+
|
|
26
|
+
const reached = (s: StepStatus) => s !== "upcoming";
|
|
27
|
+
|
|
28
|
+
interface StepperConfig {
|
|
29
|
+
orientation: StepOrientation;
|
|
30
|
+
color: string;
|
|
31
|
+
/** Pulse the current node — signals an actively-running sequence (an agent
|
|
32
|
+
* run streaming its steps). Off for a static wizard/checklist. */
|
|
33
|
+
live: boolean;
|
|
34
|
+
}
|
|
35
|
+
const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false });
|
|
7
36
|
|
|
8
37
|
export interface StepperProps {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
38
|
+
/** Compound form: `<Stepper><Step status>…</Step></Stepper>` — swappable content. */
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
/** Data form: ordered labels; `current` derives each step's status. Ignored when `children` is set. */
|
|
41
|
+
steps?: string[];
|
|
42
|
+
current?: number;
|
|
43
|
+
orientation?: StepOrientation;
|
|
44
|
+
live?: boolean;
|
|
45
|
+
/** Accent ink for reached nodes + the connecting track. Defaults to neutral ink. */
|
|
15
46
|
color?: string;
|
|
16
47
|
accessibilityLabel?: string;
|
|
17
48
|
}
|
|
18
49
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
interface StepPositional {
|
|
51
|
+
_last?: boolean;
|
|
52
|
+
_leftFilled?: boolean;
|
|
53
|
+
_rightFilled?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface StepProps extends StepPositional {
|
|
57
|
+
status: StepStatus;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
/** Make the step navigable; the active one holds a wash so a panel can sit beside it (vertical). */
|
|
60
|
+
onPress?: () => void;
|
|
61
|
+
active?: boolean;
|
|
62
|
+
accessibilityLabel?: string;
|
|
23
63
|
}
|
|
24
64
|
|
|
25
65
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
66
|
+
* Progress through an ordered sequence — done · current · upcoming on a
|
|
67
|
+
* connecting track (horizontal) or spine (vertical). The node encodes STATUS,
|
|
68
|
+
* not identity: a filled dot once reached, a ring with a white centre at the
|
|
69
|
+
* current step (pulsing when `live`), a faint ring for what's ahead, a check for
|
|
70
|
+
* the terminal `complete`. Drive it the compound way (`<Step status>` children —
|
|
71
|
+
* the label is yours to compose) or the data way (`steps` + `current`). For a
|
|
72
|
+
* heterogeneous event LOG with per-row icons + expandable details use `Timeline`;
|
|
73
|
+
* for a compact stage bar use `StepProgress`.
|
|
32
74
|
*/
|
|
33
75
|
export function Stepper(props: StepperProps) {
|
|
34
|
-
const { steps, current, color = colors.zinc[
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
76
|
+
const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
|
|
77
|
+
|
|
78
|
+
const content: ReactNode =
|
|
79
|
+
children ??
|
|
80
|
+
(steps ?? []).map((label, i) => {
|
|
81
|
+
const status: StepStatus = current == null ? "upcoming" : i < current ? "done" : i === current ? "current" : "upcoming";
|
|
82
|
+
const isCurrent = status === "current";
|
|
83
|
+
return (
|
|
84
|
+
<Step key={label} status={status}>
|
|
85
|
+
<Text size="xs" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "semibold" : "regular"} style={isCurrent ? { color } : undefined}>
|
|
86
|
+
{label}
|
|
87
|
+
</Text>
|
|
88
|
+
</Step>
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
|
|
93
|
+
const statuses = items.map((c) => c.props.status);
|
|
94
|
+
const last = items.length - 1;
|
|
95
|
+
const a11y = accessibilityLabel ?? "Progress";
|
|
96
|
+
|
|
97
|
+
const positioned = items.map((child, i) =>
|
|
98
|
+
cloneElement(child, {
|
|
99
|
+
_last: i === last,
|
|
100
|
+
_leftFilled: i > 0 && reached(statuses[i - 1]),
|
|
101
|
+
_rightFilled: i < last && reached(statuses[i]),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
38
104
|
|
|
39
105
|
return (
|
|
40
|
-
<
|
|
41
|
-
accessibilityRole="progressbar"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
>
|
|
45
|
-
{steps.map((label, i) => {
|
|
46
|
-
const status = statusOf(i, current);
|
|
47
|
-
const leftFilled = i <= current && i > 0;
|
|
48
|
-
const rightFilled = i < current && i < last;
|
|
49
|
-
const isCurrent = status === "current";
|
|
50
|
-
return (
|
|
51
|
-
<View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
|
|
52
|
-
<View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
|
|
53
|
-
<View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
|
|
54
|
-
<StepDot status={status} color={color} />
|
|
55
|
-
<View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
|
|
56
|
-
</View>
|
|
57
|
-
<Text
|
|
58
|
-
size="xs"
|
|
59
|
-
color={isCurrent ? "default" : "muted"}
|
|
60
|
-
weight={isCurrent ? "semibold" : "regular"}
|
|
61
|
-
style={isCurrent ? { color } : undefined}
|
|
62
|
-
>
|
|
63
|
-
{label}
|
|
64
|
-
</Text>
|
|
65
|
-
</View>
|
|
66
|
-
);
|
|
67
|
-
})}
|
|
68
|
-
</View>
|
|
106
|
+
<StepperContext.Provider value={{ orientation, color, live }}>
|
|
107
|
+
<View accessibilityRole="progressbar" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
|
|
108
|
+
{positioned}
|
|
109
|
+
</View>
|
|
110
|
+
</StepperContext.Provider>
|
|
69
111
|
);
|
|
70
112
|
}
|
|
71
113
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
export function Step(props: StepProps) {
|
|
115
|
+
const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
116
|
+
const { orientation, color, live } = useContext(StepperContext);
|
|
117
|
+
|
|
118
|
+
if (orientation === "horizontal") {
|
|
119
|
+
return (
|
|
120
|
+
<View style={styles.hStep}>
|
|
121
|
+
<View style={styles.hTrackRow}>
|
|
122
|
+
<View style={[styles.hTrack, { backgroundColor: _leftFilled ? color : colors.zinc[200] }]} />
|
|
123
|
+
<Marker status={status} color={color} live={live} />
|
|
124
|
+
<View style={[styles.hTrack, { backgroundColor: _rightFilled ? color : colors.zinc[200] }]} />
|
|
125
|
+
</View>
|
|
126
|
+
<View style={styles.hLabel}>{children}</View>
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
75
129
|
}
|
|
130
|
+
|
|
131
|
+
const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
|
|
132
|
+
// Each step rises + fades into place on mount — so a streamed feed reads as
|
|
133
|
+
// steps APPEARING, not popping in. Animates once (on mount); a status change
|
|
134
|
+
// (current → done) re-renders without re-animating.
|
|
76
135
|
return (
|
|
77
|
-
<
|
|
78
|
-
style={
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
<AnimationFadeIn translateY={6}>
|
|
137
|
+
<View style={styles.vItem}>
|
|
138
|
+
<View style={styles.vSpineCol}>
|
|
139
|
+
<Marker status={status} color={color} live={live} />
|
|
140
|
+
{!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
|
|
141
|
+
</View>
|
|
142
|
+
<View style={[styles.vContent, !_last ? styles.vGap : null]}>
|
|
143
|
+
{onPress ? (
|
|
144
|
+
<PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
|
|
145
|
+
{body}
|
|
146
|
+
</PressableHighlight>
|
|
147
|
+
) : (
|
|
148
|
+
body
|
|
149
|
+
)}
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
</AnimationFadeIn>
|
|
87
153
|
);
|
|
88
154
|
}
|
|
155
|
+
|
|
156
|
+
// The node — status drives fill, never size. Monochrome: reached fills with ink,
|
|
157
|
+
// the current step is a ring around white (pulsing when live), what's ahead is a
|
|
158
|
+
// faint grey ring, the terminal is a checked fill.
|
|
159
|
+
function Marker({ status, color, live }: { status: StepStatus; color: string; live: boolean }) {
|
|
160
|
+
if (status === "done") {
|
|
161
|
+
// A passed step: filled in the accent ink with a white check.
|
|
162
|
+
return (
|
|
163
|
+
<View style={[styles.disc, { backgroundColor: color }]}>
|
|
164
|
+
<Icon name="check" size={10} color={colors.white} />
|
|
165
|
+
</View>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (status === "complete") {
|
|
169
|
+
// The terminal "Done" — a blackish ring with a white centre and a black check.
|
|
170
|
+
return (
|
|
171
|
+
<View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2, borderColor: colors.zinc[900] }]}>
|
|
172
|
+
<Icon name="check" size={10} color={colors.zinc[900]} />
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (status === "warning") {
|
|
177
|
+
return (
|
|
178
|
+
<View style={[styles.disc, { backgroundColor: solid("amber") }]}>
|
|
179
|
+
<View style={styles.shortBar} />
|
|
180
|
+
</View>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (status === "current") {
|
|
184
|
+
return (
|
|
185
|
+
<View style={styles.discWrap}>
|
|
186
|
+
{live ? <Pulse color={color} /> : null}
|
|
187
|
+
<View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2.5, borderColor: color }]} />
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 1.5, borderColor: colors.zinc[300] }]} />;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An expanding, fading halo behind the current node — the "live" pulse.
|
|
195
|
+
function Pulse({ color }: { color: string }) {
|
|
196
|
+
const v = useRef(new Animated.Value(0)).current;
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: 1500, useNativeDriver: false }));
|
|
199
|
+
loop.start();
|
|
200
|
+
return () => loop.stop();
|
|
201
|
+
}, [v]);
|
|
202
|
+
const transform = [{ scale: v.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) }];
|
|
203
|
+
const opacity = v.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] });
|
|
204
|
+
return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const NODE = 18;
|
|
208
|
+
|
|
209
|
+
const styles = StyleSheet.create({
|
|
210
|
+
hRow: { flexDirection: "row" },
|
|
211
|
+
hStep: { flex: 1, alignItems: "center", gap: 8 },
|
|
212
|
+
hTrackRow: { flexDirection: "row", alignItems: "center", width: "100%" },
|
|
213
|
+
hTrack: { flex: 1, height: 2 },
|
|
214
|
+
hLabel: { alignItems: "center" },
|
|
215
|
+
|
|
216
|
+
vItem: { flexDirection: "row", gap: 12 },
|
|
217
|
+
vSpineCol: { width: NODE, alignItems: "center", paddingTop: 3 },
|
|
218
|
+
vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
|
|
219
|
+
vContent: { flex: 1 },
|
|
220
|
+
vGap: { paddingBottom: 12 },
|
|
221
|
+
vPress: { borderRadius: 8, marginHorizontal: -10 },
|
|
222
|
+
vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 2 },
|
|
223
|
+
vActive: { backgroundColor: colors.zinc[100] },
|
|
224
|
+
|
|
225
|
+
discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
|
|
226
|
+
disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
227
|
+
pulse: { position: "absolute", width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2, pointerEvents: "none" },
|
|
228
|
+
shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.white },
|
|
229
|
+
});
|
package/src/step_list.tsx
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
3
|
-
import { colors, solid } from "./colors";
|
|
4
|
-
import { Text } from "./text";
|
|
5
|
-
import { Icon } from "./icon";
|
|
6
|
-
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
-
|
|
8
|
-
export type StepStatus = "pending" | "current" | "done" | "warning";
|
|
9
|
-
|
|
10
|
-
export interface StepListItem {
|
|
11
|
-
id: string;
|
|
12
|
-
status: StepStatus;
|
|
13
|
-
/** Primary line — a stage, a code, a name. */
|
|
14
|
-
title: string;
|
|
15
|
-
/** Secondary muted line. */
|
|
16
|
-
subtitle?: string;
|
|
17
|
-
/** Right-aligned node — a count, a `Badge`, a time. */
|
|
18
|
-
trailing?: ReactNode;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface StepListProps {
|
|
22
|
-
steps: StepListItem[];
|
|
23
|
-
/** When set, steps become pressable to navigate; the matching step is held
|
|
24
|
-
* highlighted so its panel can be shown elsewhere. */
|
|
25
|
-
selectedId?: string;
|
|
26
|
-
onStepPress?: (id: string) => void;
|
|
27
|
-
accessibilityLabel?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* A vertical step sequence on a connecting spine — done · now · up next — for a
|
|
32
|
-
* guided run, a staged process, a checklist. Every status is the SAME 20px node
|
|
33
|
-
* (filled once reached, hollow while pending), so the spine reads as one line no
|
|
34
|
-
* matter which statuses appear or in what order. Unlike `Stepper` (horizontal) or
|
|
35
|
-
* `StepProgress` (a bar), and unlike `Timeline` (past events only), it models
|
|
36
|
-
* future/pending steps. Pass `onStepPress` to make the steps NAVIGABLE — the
|
|
37
|
-
* selected step is held highlighted so the host can show its panel beside the list.
|
|
38
|
-
*/
|
|
39
|
-
export function StepList(props: StepListProps) {
|
|
40
|
-
const { steps, selectedId, onStepPress, accessibilityLabel } = props;
|
|
41
|
-
return (
|
|
42
|
-
<View accessibilityLabel={accessibilityLabel}>
|
|
43
|
-
{steps.map((s, i) => {
|
|
44
|
-
// The focused step: the navigated one when navigable, else "now".
|
|
45
|
-
const active = selectedId != null ? s.id === selectedId : s.status === "current";
|
|
46
|
-
const past = s.status === "done" || s.status === "warning";
|
|
47
|
-
const content = (
|
|
48
|
-
<View style={styles.contentRow}>
|
|
49
|
-
<View style={{ flex: 1, gap: 1 }}>
|
|
50
|
-
<Text size="sm" weight={active ? "medium" : "regular"} color={past && !active ? "muted" : "default"}>
|
|
51
|
-
{s.title}
|
|
52
|
-
</Text>
|
|
53
|
-
{s.subtitle ? (
|
|
54
|
-
<Text size="xs" color="muted" numberOfLines={1}>{s.subtitle}</Text>
|
|
55
|
-
) : null}
|
|
56
|
-
</View>
|
|
57
|
-
{s.trailing}
|
|
58
|
-
</View>
|
|
59
|
-
);
|
|
60
|
-
return (
|
|
61
|
-
<View key={s.id} style={styles.item}>
|
|
62
|
-
<View style={styles.spineCol}>
|
|
63
|
-
<Marker status={s.status} />
|
|
64
|
-
{i < steps.length - 1 ? <View style={styles.spine} /> : null}
|
|
65
|
-
</View>
|
|
66
|
-
<View style={[styles.contentCol, i < steps.length - 1 ? styles.contentGap : null]}>
|
|
67
|
-
{onStepPress ? (
|
|
68
|
-
<PressableHighlight onPress={() => onStepPress(s.id)} style={[styles.rowBox, active ? styles.active : null]}>
|
|
69
|
-
{content}
|
|
70
|
-
</PressableHighlight>
|
|
71
|
-
) : (
|
|
72
|
-
<View style={[styles.rowBox, active ? styles.active : null]}>{content}</View>
|
|
73
|
-
)}
|
|
74
|
-
</View>
|
|
75
|
-
</View>
|
|
76
|
-
);
|
|
77
|
-
})}
|
|
78
|
-
</View>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** One uniform 20px node; status drives fill, not size or shape. */
|
|
83
|
-
function Marker({ status }: { status: StepStatus }) {
|
|
84
|
-
if (status === "pending") {
|
|
85
|
-
return <View style={[styles.disc, styles.discPending]} />;
|
|
86
|
-
}
|
|
87
|
-
if (status === "current") {
|
|
88
|
-
return (
|
|
89
|
-
<View style={[styles.disc, { backgroundColor: solid("blue") }]}>
|
|
90
|
-
<View style={styles.currentDot} />
|
|
91
|
-
</View>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
if (status === "warning") {
|
|
95
|
-
return (
|
|
96
|
-
<View style={[styles.disc, { backgroundColor: solid("amber") }]}>
|
|
97
|
-
<View style={styles.shortBar} />
|
|
98
|
-
</View>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return (
|
|
102
|
-
<View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
|
|
103
|
-
<Icon name="check" size={12} color={colors.background} />
|
|
104
|
-
</View>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const NODE = 20;
|
|
109
|
-
|
|
110
|
-
const styles = StyleSheet.create({
|
|
111
|
-
item: { flexDirection: "row", gap: 12 },
|
|
112
|
-
// paddingTop pairs with rowBox.paddingVertical so the node centres on the
|
|
113
|
-
// FIRST line of content, not on a multi-line block.
|
|
114
|
-
spineCol: { width: NODE, alignItems: "center", paddingTop: 4 },
|
|
115
|
-
disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
116
|
-
discPending: { backgroundColor: colors.background, borderWidth: 1.5, borderColor: colors.zinc[300] },
|
|
117
|
-
currentDot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.background },
|
|
118
|
-
shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.background },
|
|
119
|
-
spine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 4 },
|
|
120
|
-
contentCol: { flex: 1 },
|
|
121
|
-
// The inter-step gap — on every step but the last, so the list ends flush and
|
|
122
|
-
// a container's own padding isn't doubled at the bottom.
|
|
123
|
-
contentGap: { paddingBottom: 12 },
|
|
124
|
-
contentRow: { flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 },
|
|
125
|
-
// press + plain share one box so the active wash looks identical either way.
|
|
126
|
-
rowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginHorizontal: -10, flexDirection: "row", alignItems: "flex-start" },
|
|
127
|
-
active: { backgroundColor: colors.zinc[100] },
|
|
128
|
-
});
|