@lotics/ui 14.3.1 → 15.1.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 +12 -28
- package/MIGRATION.md +66 -0
- package/docs/ai_patterns.md +38 -16
- package/docs/catalog.md +71 -14
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +15 -2
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +13 -1
- package/src/agent_progress.tsx +11 -7
- package/src/agent_run.tsx +114 -43
- package/src/agent_transform.test.ts +96 -0
- package/src/agent_transform.ts +17 -5
- package/src/approval_prompt.tsx +70 -0
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +21 -8
- package/src/message_actions.tsx +111 -0
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { IconButton } from "./icon_button";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
|
|
7
|
+
/** The branch/version pager slot — the previous/next controls over a message's
|
|
8
|
+
* sibling versions. Rendered only when `total > 1`. */
|
|
9
|
+
export interface MessageActionsBranch {
|
|
10
|
+
current: number;
|
|
11
|
+
total: number;
|
|
12
|
+
onPrevious: () => void;
|
|
13
|
+
onNext: () => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MessageActionsProps {
|
|
18
|
+
/** Copy the message. The bar OWNS the copied feedback — the icon flips to a
|
|
19
|
+
* check and the tooltip flips to "Copied" for ~2s (the timer is cleared on
|
|
20
|
+
* unmount). The handler only performs the clipboard write — it reads the
|
|
21
|
+
* message content the bar cannot see. */
|
|
22
|
+
onCopy?: () => void | Promise<void>;
|
|
23
|
+
/** Regenerate the (assistant) message. */
|
|
24
|
+
onRegenerate?: () => void;
|
|
25
|
+
/** Edit the (user) message. */
|
|
26
|
+
onEdit?: () => void;
|
|
27
|
+
/** The sibling-version pager, absorbed as an internal part of the bar. */
|
|
28
|
+
branch?: MessageActionsBranch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The single shared per-message action bar — the branch/version pager, copy
|
|
33
|
+
* (with the owned copied-feedback contract), regenerate, and edit. Each action
|
|
34
|
+
* renders ONLY when its handler is provided, so an assistant bar (copy +
|
|
35
|
+
* regenerate) and a user bar (copy + edit) are the same component with a
|
|
36
|
+
* different handler set; the pager renders when `branch.total > 1`. The bar
|
|
37
|
+
* hugs its content, so the parent controls the row's side (left for an
|
|
38
|
+
* assistant turn, right for a user bubble). Strings resolve through the
|
|
39
|
+
* `messageActions` locale slice.
|
|
40
|
+
*/
|
|
41
|
+
export function MessageActions(props: MessageActionsProps) {
|
|
42
|
+
const { onCopy, onRegenerate, onEdit, branch } = props;
|
|
43
|
+
const labels = useLoticsLocale().messageActions;
|
|
44
|
+
const [copied, setCopied] = useState(false);
|
|
45
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
46
|
+
|
|
47
|
+
// The copied flag flips back after a beat; the timer is cleared on unmount so
|
|
48
|
+
// a bar that disappears (last message loses its actions) never sets state on
|
|
49
|
+
// an unmounted tree.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
return () => {
|
|
52
|
+
if (timer.current) clearTimeout(timer.current);
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const handleCopy = useCallback(async () => {
|
|
57
|
+
await onCopy?.();
|
|
58
|
+
setCopied(true);
|
|
59
|
+
if (timer.current) clearTimeout(timer.current);
|
|
60
|
+
timer.current = setTimeout(() => setCopied(false), 2000);
|
|
61
|
+
}, [onCopy]);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View style={styles.bar}>
|
|
65
|
+
{branch ? <BranchPager branch={branch} previousLabel={labels.previousVersion} nextLabel={labels.nextVersion} /> : null}
|
|
66
|
+
{onCopy ? (
|
|
67
|
+
<IconButton
|
|
68
|
+
testID="copy-button"
|
|
69
|
+
onPress={handleCopy}
|
|
70
|
+
icon={copied ? "check" : "copy"}
|
|
71
|
+
tooltip={copied ? labels.copied : labels.copy}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
{onRegenerate ? (
|
|
75
|
+
<IconButton testID="regenerate-button" onPress={onRegenerate} icon="refresh-cw" tooltip={labels.regenerate} />
|
|
76
|
+
) : null}
|
|
77
|
+
{onEdit ? (
|
|
78
|
+
<IconButton testID="edit-button" onPress={onEdit} icon="pencil" tooltip={labels.edit} />
|
|
79
|
+
) : null}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The sibling-version pager: [‹] {current} / {total} [›]. A single version has
|
|
85
|
+
// nothing to page, so it renders nothing.
|
|
86
|
+
function BranchPager({ branch, previousLabel, nextLabel }: { branch: MessageActionsBranch; previousLabel: string; nextLabel: string }) {
|
|
87
|
+
const { current, total, onPrevious, onNext, disabled } = branch;
|
|
88
|
+
if (total <= 1) return null;
|
|
89
|
+
return (
|
|
90
|
+
<View testID="branch-pagination" style={styles.branch}>
|
|
91
|
+
<IconButton testID="branch-prev" icon="chevron-left" tooltip={previousLabel} onPress={onPrevious} disabled={disabled || current <= 1} />
|
|
92
|
+
<Text size="xs" color="muted">
|
|
93
|
+
{current} / {total}
|
|
94
|
+
</Text>
|
|
95
|
+
<IconButton testID="branch-next" icon="chevron-right" tooltip={nextLabel} onPress={onNext} disabled={disabled || current >= total} />
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const styles = StyleSheet.create({
|
|
101
|
+
bar: {
|
|
102
|
+
flexDirection: "row",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
gap: 4,
|
|
105
|
+
},
|
|
106
|
+
branch: {
|
|
107
|
+
flexDirection: "row",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
gap: 2,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { UsePasteFilesOptions } from "./file_intake";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native stand-in for the web clipboard sink.
|
|
5
|
+
*
|
|
6
|
+
* There is no document-level `paste` event on React Native — the OS clipboard is
|
|
7
|
+
* read imperatively — so the hook is a no-op and the surface's own Add-file CTA
|
|
8
|
+
* stays the intake path. The file exists so a shared screen can call
|
|
9
|
+
* `usePasteFiles` unconditionally without the DOM code ever reaching Metro.
|
|
10
|
+
*
|
|
11
|
+
* Web targets resolve `use_paste_files.web.ts` instead (Metro's `.web` extension
|
|
12
|
+
* resolution; the package's `react-native` export condition).
|
|
13
|
+
*/
|
|
14
|
+
export function usePasteFiles(_options: UsePasteFilesOptions): void {}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { filesFromTransfer, selectPasteSink, type UsePasteFilesOptions } from "./file_intake";
|
|
3
|
+
import { resolveRegionNode } from "./dom_region";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web variant: the document-level clipboard sink. Isolated into its own module
|
|
7
|
+
* so the native bundle (which resolves `use_paste_files.ts`) never references
|
|
8
|
+
* the DOM.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type PasteSink = (event: ClipboardEvent) => void;
|
|
12
|
+
|
|
13
|
+
interface RegisteredSink {
|
|
14
|
+
sink: PasteSink;
|
|
15
|
+
/** Evaluated at dispatch time: does this sink's region contain focus? A
|
|
16
|
+
* region-less sink is always false — it routes by stack order alone. */
|
|
17
|
+
hasFocusWithin: () => boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The active sinks, most recently subscribed LAST, behind ONE document listener.
|
|
22
|
+
* A paste is routed by `selectPasteSink`: the TOP-MOST sink whose region holds
|
|
23
|
+
* focus wins (so two file targets on one layer each take the paste when the user
|
|
24
|
+
* is working in them), else the top of the stack (right for modal stacking — a
|
|
25
|
+
* dialog over a screen).
|
|
26
|
+
*/
|
|
27
|
+
const sinks: RegisteredSink[] = [];
|
|
28
|
+
let listening = false;
|
|
29
|
+
|
|
30
|
+
function dispatch(event: ClipboardEvent) {
|
|
31
|
+
// An inner handler (a composer with its own attach path) already claimed it.
|
|
32
|
+
if (event.defaultPrevented) return;
|
|
33
|
+
const chosen = selectPasteSink(
|
|
34
|
+
sinks.map((registered) => ({ sink: registered.sink, focused: registered.hasFocusWithin() })),
|
|
35
|
+
);
|
|
36
|
+
chosen?.(event);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pushSink(registered: RegisteredSink): () => void {
|
|
40
|
+
sinks.push(registered);
|
|
41
|
+
if (!listening) {
|
|
42
|
+
document.addEventListener("paste", dispatch);
|
|
43
|
+
listening = true;
|
|
44
|
+
}
|
|
45
|
+
return () => {
|
|
46
|
+
const index = sinks.lastIndexOf(registered);
|
|
47
|
+
if (index >= 0) sinks.splice(index, 1);
|
|
48
|
+
if (sinks.length === 0 && listening) {
|
|
49
|
+
document.removeEventListener("paste", dispatch);
|
|
50
|
+
listening = false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ctrl/Cmd+V adds files — the paste half of file intake, alongside the drop
|
|
57
|
+
* (`FileDropTarget` / `FileDropzone`) and the pick (`pickFiles`). Reads whatever
|
|
58
|
+
* the clipboard carries (a copied file, or a screenshot, which some engines
|
|
59
|
+
* surface only through `clipboardData.items`), filters it by `accept`, and hands
|
|
60
|
+
* it to `onFiles`.
|
|
61
|
+
*
|
|
62
|
+
* Scope it with `enabled` so only the open surface listens. Pass `region` (the
|
|
63
|
+
* ref of the surface's DOM region) to route by FOCUS: when the paste fires while
|
|
64
|
+
* focus is inside this region, this sink wins over a later-mounted one — so two
|
|
65
|
+
* peer file sections each take the paste when the user is in them. Without a
|
|
66
|
+
* region it routes by stack order (the modal-stacking default). A paste carrying
|
|
67
|
+
* NO acceptable file is left entirely alone — typing Ctrl+V in a text field
|
|
68
|
+
* still pastes text.
|
|
69
|
+
*/
|
|
70
|
+
export function usePasteFiles(options: UsePasteFilesOptions): void {
|
|
71
|
+
const { onFiles, accept, multiple = true, enabled = true, region } = options;
|
|
72
|
+
|
|
73
|
+
// Read through a ref so the sink subscribes ONCE per enabled window. Keying
|
|
74
|
+
// the effect on `onFiles` instead would re-push on every parent render —
|
|
75
|
+
// routine with an inline callback — and a background surface re-rendering
|
|
76
|
+
// would jump the stack and steal the next paste.
|
|
77
|
+
const latest = useRef({ onFiles, accept, multiple, region });
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
latest.current = { onFiles, accept, multiple, region };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!enabled) return;
|
|
84
|
+
if (typeof document === "undefined") return;
|
|
85
|
+
return pushSink({
|
|
86
|
+
hasFocusWithin: () => {
|
|
87
|
+
const node = resolveRegionNode(latest.current.region);
|
|
88
|
+
return node !== null && node.contains(document.activeElement);
|
|
89
|
+
},
|
|
90
|
+
sink: (event) => {
|
|
91
|
+
const settings = latest.current;
|
|
92
|
+
const files = filesFromTransfer(event.clipboardData, settings);
|
|
93
|
+
if (files.length === 0) return;
|
|
94
|
+
// Claim ONLY the paste actually consumed, so a text paste stays the
|
|
95
|
+
// browser's (and the focused input's) to handle.
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
settings.onFiles(files);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}, [enabled]);
|
|
101
|
+
}
|