@lotics/ui 13.9.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 +57 -37
- package/docs/catalog.md +27 -9
- 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 +7 -2
- package/src/agent_progress.tsx +11 -12
- package/src/agent_run.tsx +82 -121
- package/src/agent_transform.ts +102 -0
- package/src/choice_list.tsx +103 -14
- package/src/clarify.tsx +31 -15
- package/src/clarify_wizard.tsx +84 -0
- package/src/json_panel.tsx +25 -13
- package/src/locale.tsx +4 -0
- package/src/primitives_purity.test.ts +15 -1
- package/src/table.tsx +5 -1
package/src/clarify.tsx
CHANGED
|
@@ -1,38 +1,54 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
-
import { ChoiceList
|
|
3
|
+
import { ChoiceList } from "./choice_list";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export interface ClarifyOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** REQUIRED for a clarify answer: the one-line "what this choice means" so the
|
|
9
|
+
* human can pick without re-deriving it from the question. */
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
export interface ClarifyProps {
|
|
8
14
|
/** The agent's question — what it needs settled to proceed. */
|
|
9
15
|
question: string;
|
|
16
|
+
/** A muted eyebrow rendered tight above the question — e.g. a wizard's "1 / 3". */
|
|
17
|
+
eyebrow?: string;
|
|
10
18
|
options: ClarifyOption[];
|
|
11
19
|
onAnswer: (value: string) => void;
|
|
12
|
-
/** The chosen option's VALUE —
|
|
13
|
-
* switchable so the human can change
|
|
20
|
+
/** The chosen option's VALUE — or, with `allowCustom`, a custom answer's free
|
|
21
|
+
* text. The picked choice shows selected; switchable so the human can change. */
|
|
14
22
|
answer?: string;
|
|
23
|
+
/** Offer an "Other…" free-text row below the options. */
|
|
24
|
+
allowCustom?: boolean;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
|
-
* The agent asks BACK — a question with selectable answer options
|
|
19
|
-
* before the run continues, freely
|
|
20
|
-
* (no card): the question,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
28
|
+
* The agent asks BACK — a question with selectable answer options (each carrying
|
|
29
|
+
* a one-line description) the human picks before the run continues, freely
|
|
30
|
+
* switchable until committed. A borderless block (no card): the question text,
|
|
31
|
+
* then divider-separated option rows (`ChoiceList`); `allowCustom` adds an
|
|
32
|
+
* "Other…" free-text row. For a SEQUENCE of questions with Back/Next/Submit
|
|
33
|
+
* navigation, use `ClarifyWizard`. Human-in-the-loop: when unsure, the agent
|
|
34
|
+
* clarifies instead of guessing wrong. Pair with `AgentRun` / `AgentProgress`.
|
|
23
35
|
*/
|
|
24
36
|
export function Clarify(props: ClarifyProps) {
|
|
25
37
|
return (
|
|
26
|
-
<View style={{ gap:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
<View style={{ gap: 12 }}>
|
|
39
|
+
{/* paddingHorizontal 8 aligns the eyebrow + question with the option/answer
|
|
40
|
+
text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
|
|
41
|
+
<View style={{ gap: 4, paddingHorizontal: 8 }}>
|
|
42
|
+
{props.eyebrow ? (
|
|
43
|
+
<Text size="xs" color="muted" weight="medium">
|
|
44
|
+
{props.eyebrow}
|
|
45
|
+
</Text>
|
|
46
|
+
) : null}
|
|
31
47
|
<Text size="sm" weight="medium">
|
|
32
48
|
{props.question}
|
|
33
49
|
</Text>
|
|
34
50
|
</View>
|
|
35
|
-
<ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} />
|
|
51
|
+
<ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} allowCustom={props.allowCustom} />
|
|
36
52
|
</View>
|
|
37
53
|
);
|
|
38
54
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Clarify, type ClarifyOption } from "./clarify";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
|
|
7
|
+
export interface ClarifyWizardQuestion {
|
|
8
|
+
question: string;
|
|
9
|
+
answers: ClarifyOption[];
|
|
10
|
+
/** Offer an "Other…" free-text answer for this question. */
|
|
11
|
+
allowCustom?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClarifyWizardAnswer {
|
|
15
|
+
/** The chosen option's value, or the custom free text. */
|
|
16
|
+
value: string;
|
|
17
|
+
/** True when the value came from the "Other…" row (not one of the options). */
|
|
18
|
+
custom: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClarifyWizardProps {
|
|
22
|
+
questions: ClarifyWizardQuestion[];
|
|
23
|
+
/** Fires when every question is answered and the human submits — one answer
|
|
24
|
+
* per question, aligned by index. */
|
|
25
|
+
onSubmit: (answers: ClarifyWizardAnswer[]) => void;
|
|
26
|
+
onCancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A SEQUENCE of `Clarify` questions the human works through one at a time —
|
|
31
|
+
* Back / Next / Cancel / Submit with a position indicator. Each step reuses
|
|
32
|
+
* `Clarify` (question + described answers + optional "Other…"). You can only
|
|
33
|
+
* advance once the current question is answered; Submit fires when the last is.
|
|
34
|
+
* The multi-question form of the agent's ask-back; single questions use `Clarify`.
|
|
35
|
+
*/
|
|
36
|
+
export function ClarifyWizard(props: ClarifyWizardProps) {
|
|
37
|
+
const { questions, onSubmit, onCancel } = props;
|
|
38
|
+
const labels = useLoticsLocale().clarify;
|
|
39
|
+
const [index, setIndex] = useState(0);
|
|
40
|
+
const [answers, setAnswers] = useState<(string | undefined)[]>(() => questions.map(() => undefined));
|
|
41
|
+
|
|
42
|
+
const total = questions.length;
|
|
43
|
+
const q = questions[index];
|
|
44
|
+
const current = answers[index];
|
|
45
|
+
// Answered = a non-empty string: an option value, or filled custom text ("" is
|
|
46
|
+
// "Other" picked but not yet typed — not answered).
|
|
47
|
+
const answered = typeof current === "string" && current.length > 0;
|
|
48
|
+
const isLast = index === total - 1;
|
|
49
|
+
|
|
50
|
+
const setAnswer = (v: string) =>
|
|
51
|
+
setAnswers((prev) => {
|
|
52
|
+
const next = [...prev];
|
|
53
|
+
next[index] = v;
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const submit = () =>
|
|
58
|
+
onSubmit(
|
|
59
|
+
questions.map((question, i) => {
|
|
60
|
+
const value = answers[i] ?? "";
|
|
61
|
+
return { value, custom: !question.answers.some((a) => a.value === value) };
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<View style={{ gap: 16 }}>
|
|
67
|
+
{/* The step position is an eyebrow above the question (no progress bar); the
|
|
68
|
+
footer aligns to the same 8px inset as the question and answers. Keyed by
|
|
69
|
+
index so each step's custom-answer draft is its own (no bleed across steps). */}
|
|
70
|
+
<Clarify key={index} eyebrow={`${index + 1} / ${total}`} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
|
|
71
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
|
|
72
|
+
<Button title={labels.cancel} color="muted" onPress={onCancel} />
|
|
73
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
74
|
+
{index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
|
|
75
|
+
{isLast ? (
|
|
76
|
+
<Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
|
|
77
|
+
) : (
|
|
78
|
+
<Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
</View>
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
}
|
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
|
},
|
package/src/locale.tsx
CHANGED
|
@@ -57,6 +57,8 @@ export interface LoticsLocale {
|
|
|
57
57
|
/** The `Inline*` editor family: the shared save-error line and the
|
|
58
58
|
* text-editor Save/Cancel tooltips. */
|
|
59
59
|
inline: { saveError: string; save: string; cancel: string };
|
|
60
|
+
/** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
|
|
61
|
+
clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
|
|
60
62
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
61
63
|
ledger: { rowDetails: (label: string) => string };
|
|
62
64
|
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
@@ -159,6 +161,7 @@ export const en: LoticsLocale = {
|
|
|
159
161
|
dangerZone: { title: "Danger zone" },
|
|
160
162
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
161
163
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
164
|
+
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
162
165
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
163
166
|
sectionHeading: { info: "About this data" },
|
|
164
167
|
chip: { remove: "Remove" },
|
|
@@ -254,6 +257,7 @@ export const vi: LoticsLocale = {
|
|
|
254
257
|
dangerZone: { title: "Vùng nguy hiểm" },
|
|
255
258
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
256
259
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
260
|
+
clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
|
|
257
261
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
258
262
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
259
263
|
chip: { remove: "Xóa" },
|
|
@@ -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
|
}
|
package/src/table.tsx
CHANGED
|
@@ -315,7 +315,11 @@ const styles = StyleSheet.create({
|
|
|
315
315
|
// Divider-separated.
|
|
316
316
|
headerBand: {
|
|
317
317
|
paddingHorizontal: ROW_GUTTER,
|
|
318
|
-
|
|
318
|
+
// NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
|
|
319
|
+
// the top spacing (a Section's gap, a Card's padding, any `gap` above). A band
|
|
320
|
+
// paddingTop would stack on that and double-gap the column header; the bottom
|
|
321
|
+
// padding is the band's own — it spaces the header off the hairline below.
|
|
322
|
+
paddingBottom: 10,
|
|
319
323
|
flexDirection: "row",
|
|
320
324
|
alignItems: "center",
|
|
321
325
|
gap: COLUMN_GAP,
|