@lotics/ui 14.2.0 → 14.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ai_patterns.md +23 -8
- package/docs/catalog.md +7 -4
- package/docs/templates.md +32 -20
- package/examples/tpl_item_list.tsx +303 -58
- package/examples/tpl_record.tsx +24 -15
- package/package.json +1 -1
- package/src/follow_scroll.tsx +32 -37
- package/examples/tpl_documents.tsx +0 -808
package/examples/tpl_record.tsx
CHANGED
|
@@ -66,6 +66,8 @@ 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
68
|
import { AgentRun } from "@lotics/ui/agent_run";
|
|
69
|
+
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
70
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
69
71
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
70
72
|
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
71
73
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
@@ -92,9 +94,8 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
|
92
94
|
// · Comments — the discussion thread. When people collaborate here.
|
|
93
95
|
// · Tasks — the working checklist. Multi-step records with owners.
|
|
94
96
|
// · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
|
|
95
|
-
// fork (
|
|
96
|
-
//
|
|
97
|
-
// here, in the toolbar dialog there).
|
|
97
|
+
// fork (the Agents "Document desk" pattern — this template
|
|
98
|
+
// is its worked example).
|
|
98
99
|
// · Customer — the LINKED PARTY: another record referenced, never
|
|
99
100
|
// edited here (the linked-record box + drawer).
|
|
100
101
|
// · Fees — the MONEY LEDGER: cost/charge lines, both directions.
|
|
@@ -429,11 +430,10 @@ const GUTTER = RAIL_W + RAIL_GAP;
|
|
|
429
430
|
/** The reading column's ceiling. */
|
|
430
431
|
const CONTENT_MAX = 720;
|
|
431
432
|
|
|
432
|
-
// ── the DOCUMENT DESK — the Agents "Document desk" pattern
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
// in this page's output sections, in tpl_documents' toolbar dialog).
|
|
433
|
+
// ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
|
|
434
|
+
// record's documents surface: files feed ONE "Use AI" entry that forks into
|
|
435
|
+
// extract / cross-check / edit-with-AI; generation lives in this page's
|
|
436
|
+
// output sections (the desk itself is intake-only).
|
|
437
437
|
type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
|
|
438
438
|
type Task = "extract" | "check";
|
|
439
439
|
type Phase = "fork" | "running" | "review" | "done";
|
|
@@ -701,6 +701,7 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
|
|
|
701
701
|
export function TplRecord() {
|
|
702
702
|
const id = useRef(1);
|
|
703
703
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
704
|
+
const { small } = useScreenSize();
|
|
704
705
|
|
|
705
706
|
// ── first paint = Skeleton MIRRORING the final layout, never a spinner.
|
|
706
707
|
// The 700ms mock stands in for the record query a real app awaits.
|
|
@@ -835,8 +836,8 @@ export function TplRecord() {
|
|
|
835
836
|
const taxId = customer?.taxId ?? "";
|
|
836
837
|
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
837
838
|
|
|
838
|
-
// ── the document desk (
|
|
839
|
-
//
|
|
839
|
+
// ── the document desk (see the module banner above; the record's files ARE
|
|
840
|
+
// the desk's register)
|
|
840
841
|
const [files, setFiles] = useState<Doc[]>(DOCS);
|
|
841
842
|
const sel = useSelection();
|
|
842
843
|
|
|
@@ -1885,8 +1886,7 @@ export function TplRecord() {
|
|
|
1885
1886
|
</View>
|
|
1886
1887
|
<View style={{ flex: 1 }} />
|
|
1887
1888
|
{/* no Create here — GENERATION lives in the Document set section
|
|
1888
|
-
below (this desk = intake)
|
|
1889
|
-
output section, keeps the dialog flavor in this slot */}
|
|
1889
|
+
below (this desk = intake) */}
|
|
1890
1890
|
<Button
|
|
1891
1891
|
title="Add files"
|
|
1892
1892
|
color="secondary"
|
|
@@ -2719,7 +2719,7 @@ export function TplRecord() {
|
|
|
2719
2719
|
</View>
|
|
2720
2720
|
</ScrollView>
|
|
2721
2721
|
|
|
2722
|
-
{/* ── the document desk's overlays
|
|
2722
|
+
{/* ── the document desk's overlays */}
|
|
2723
2723
|
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
2724
2724
|
<Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
|
|
2725
2725
|
<Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
|
|
@@ -2734,6 +2734,16 @@ export function TplRecord() {
|
|
|
2734
2734
|
<DialogHeader>
|
|
2735
2735
|
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
|
|
2736
2736
|
</DialogHeader>
|
|
2737
|
+
{phase === "running" ? (
|
|
2738
|
+
// The streaming feed gets its OWN scroller: FollowScroll (the chat list's
|
|
2739
|
+
// inverted mechanism) keeps the newest part in view as the run grows — a
|
|
2740
|
+
// plain DialogScrollArea would let it stream below the fold. The swap back
|
|
2741
|
+
// on the phase flip REMOUNTS the scroll area, so review opens at the TOP.
|
|
2742
|
+
// Padding mirrors DialogScrollArea.
|
|
2743
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
|
|
2744
|
+
<AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} />
|
|
2745
|
+
</FollowScroll>
|
|
2746
|
+
) : (
|
|
2737
2747
|
<DialogScrollArea>
|
|
2738
2748
|
{phase === "fork" ? (
|
|
2739
2749
|
<View style={{ gap: 16 }}>
|
|
@@ -2776,8 +2786,6 @@ export function TplRecord() {
|
|
|
2776
2786
|
</View>
|
|
2777
2787
|
) : null}
|
|
2778
2788
|
|
|
2779
|
-
{phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
|
|
2780
|
-
|
|
2781
2789
|
{phase === "review" && task === "extract" ? (
|
|
2782
2790
|
<View style={{ gap: 16 }}>
|
|
2783
2791
|
<View style={{ gap: 8 }}>
|
|
@@ -2904,6 +2912,7 @@ export function TplRecord() {
|
|
|
2904
2912
|
<CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to ${code}`} summary="The files stay on the record — each updated field keeps its source document." />
|
|
2905
2913
|
) : null}
|
|
2906
2914
|
</DialogScrollArea>
|
|
2915
|
+
)}
|
|
2907
2916
|
{phase === "fork" ? (
|
|
2908
2917
|
<DialogFooter>
|
|
2909
2918
|
{uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
|
package/package.json
CHANGED
package/src/follow_scroll.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { FlatList, StyleProp, View, ViewStyle } from "react-native";
|
|
3
3
|
|
|
4
4
|
export interface FollowScrollProps {
|
|
5
5
|
children: ReactNode;
|
|
@@ -9,50 +9,45 @@ export interface FollowScrollProps {
|
|
|
9
9
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
// pinned, so sub-pixel scroll positions and streaming-growth rounding never
|
|
14
|
-
// silently unpin the follow.
|
|
15
|
-
const PIN_THRESHOLD = 40;
|
|
12
|
+
const DATA = ["content"];
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
15
|
* A scroll container that FOLLOWS its growing content — pinned to the bottom, it
|
|
19
16
|
* keeps the newest content in view as children stream in (an `AgentRun` in a
|
|
20
|
-
* dialog, a log feed). Scrolling up
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* dialog, a log feed). Scrolling up releases the pin so the human can read
|
|
18
|
+
* history; scrolling back to the bottom re-pins.
|
|
19
|
+
*
|
|
20
|
+
* Implemented as a single-cell INVERTED list — the same mechanism as a chat
|
|
21
|
+
* message list — so the follow happens at LAYOUT level: the browser paints each
|
|
22
|
+
* growth already pinned (no scroll-after-paint flash, no scroll math). Short
|
|
23
|
+
* content still reads top-down (the content is end-justified in inverted
|
|
24
|
+
* coordinates); once it overflows, the newest edge stays in view natively. The
|
|
25
|
+
* inverted pattern's quirks (scrollbar thumb runs opposite, keyboard paging
|
|
26
|
+
* inverted) match the chat surface users already know.
|
|
24
27
|
*/
|
|
25
28
|
export function FollowScroll(props: FollowScrollProps) {
|
|
26
29
|
const { children, style, contentContainerStyle } = props;
|
|
27
|
-
const scrollRef = useRef<ScrollView>(null);
|
|
28
|
-
// Pinned state lives in a ref — it changes on every scroll tick and must never
|
|
29
|
-
// re-render the (streaming, already-busy) subtree.
|
|
30
|
-
const pinnedRef = useRef(true);
|
|
31
|
-
|
|
32
|
-
const followIfPinned = () => {
|
|
33
|
-
if (pinnedRef.current) scrollRef.current?.scrollToEnd({ animated: false });
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
37
|
-
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
38
|
-
const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
|
|
39
|
-
pinnedRef.current = distanceFromBottom < PIN_THRESHOLD;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
30
|
return (
|
|
43
|
-
<
|
|
44
|
-
|
|
31
|
+
<FlatList
|
|
32
|
+
inverted
|
|
33
|
+
data={DATA}
|
|
34
|
+
keyExtractor={(k) => k}
|
|
35
|
+
// The consumer's content styles live on an inner wrapper INSIDE the cell —
|
|
36
|
+
// the cell renders upright (double flip), so authored padding keeps its
|
|
37
|
+
// visual orientation; the flipped outer container must not carry it.
|
|
38
|
+
renderItem={() => <View style={contentContainerStyle}>{children}</View>}
|
|
39
|
+
// Children identity changes every stream tick; without extraData the list
|
|
40
|
+
// would keep rendering the mounted cell's stale content.
|
|
41
|
+
extraData={children}
|
|
45
42
|
style={style}
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
onContentSizeChange={followIfPinned}
|
|
50
|
-
onLayout={followIfPinned}
|
|
51
|
-
onScroll={onScroll}
|
|
52
|
-
scrollEventThrottle={16}
|
|
43
|
+
// flexGrow + flex-end top-aligns SHORT content (flex-end is the visual TOP
|
|
44
|
+
// in inverted coordinates), so a feed reads top-down until it overflows.
|
|
45
|
+
contentContainerStyle={followStyles.grow}
|
|
53
46
|
nestedScrollEnabled
|
|
54
|
-
|
|
55
|
-
{children}
|
|
56
|
-
</ScrollView>
|
|
47
|
+
/>
|
|
57
48
|
);
|
|
58
49
|
}
|
|
50
|
+
|
|
51
|
+
const followStyles = {
|
|
52
|
+
grow: { flexGrow: 1, justifyContent: "flex-end" } as const,
|
|
53
|
+
};
|