@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.
@@ -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 (shared with tpl_documents change BOTH; the one
96
- // divergence: generation lives in the output sections
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 (tpl_documents),
433
- // carried as the record's documents surface: files feed ONE "Use AI" entry
434
- // that forks into extract / cross-check / edit-with-AI. The two templates
435
- // share this desk — change it in BOTH (the one divergence: generation lives
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 (carried verbatim from tpl_documents see the module
839
- // banner above; the record's files ARE the desk's register)
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); tpl_documents, whose page has no
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 (verbatim from tpl_documents) */}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "14.2.0",
3
+ "version": "14.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -1,5 +1,5 @@
1
- import { useRef, type ReactNode } from "react";
2
- import { ScrollView, StyleProp, ViewStyle, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
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
- // "At the bottom" tolerance: within this many px of the end still counts as
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 UNPINS it so the human can read history while
21
- * the content keeps growing; scrolling back to the bottom re-pins. Chat doesn't
22
- * need this (its message list is inverted, which follows by construction) — this
23
- * is for the plain top-down containers everywhere else: dialogs, drawers, panels.
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
- <ScrollView
44
- ref={scrollRef}
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
- contentContainerStyle={contentContainerStyle}
47
- // Content grew (a new part streamed in) or the frame resized (dialog
48
- // re-measured) — both move the bottom edge, so both re-follow when pinned.
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
+ };