@opengeni/react-native 1.4.5-canary.37615713321001
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/LICENSE +190 -0
- package/README.md +74 -0
- package/dist/adapters.d.ts +53 -0
- package/dist/adapters.js +3 -0
- package/dist/adapters.js.map +1 -0
- package/dist/attachments.d.ts +75 -0
- package/dist/chunk-3VSZAQ7G.js +391 -0
- package/dist/chunk-3VSZAQ7G.js.map +1 -0
- package/dist/chunk-5ZVK7WKA.js +88 -0
- package/dist/chunk-5ZVK7WKA.js.map +1 -0
- package/dist/chunk-EEDIMY3L.js +240 -0
- package/dist/chunk-EEDIMY3L.js.map +1 -0
- package/dist/chunk-M23SDI4R.js +83 -0
- package/dist/chunk-M23SDI4R.js.map +1 -0
- package/dist/chunk-PHJBSAR3.js +2006 -0
- package/dist/chunk-PHJBSAR3.js.map +1 -0
- package/dist/environment.d.ts +19 -0
- package/dist/expo.d.ts +25 -0
- package/dist/expo.js +267 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +3345 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +262 -0
- package/dist/read-acknowledgement.d.ts +39 -0
- package/dist/realtime/call-events.d.ts +11 -0
- package/dist/realtime/call-view.d.ts +26 -0
- package/dist/realtime/expo-call.d.ts +11 -0
- package/dist/realtime/native-call.d.ts +104 -0
- package/dist/realtime/native-realtime.d.ts +35 -0
- package/dist/realtime/use-native-session-realtime.d.ts +25 -0
- package/dist/session-view.d.ts +87 -0
- package/dist/timeline/activity.d.ts +34 -0
- package/dist/timeline/composer.d.ts +86 -0
- package/dist/timeline/controls.d.ts +27 -0
- package/dist/timeline/decisions.d.ts +29 -0
- package/dist/timeline/feedback.d.ts +26 -0
- package/dist/timeline/icon-nodes.generated.d.ts +738 -0
- package/dist/timeline/icon.d.ts +11 -0
- package/dist/timeline/index.d.ts +32 -0
- package/dist/timeline/index.js +5419 -0
- package/dist/timeline/index.js.map +1 -0
- package/dist/timeline/markdown.d.ts +11 -0
- package/dist/timeline/markdown.js +415 -0
- package/dist/timeline/markdown.js.map +1 -0
- package/dist/timeline/message-attachments.d.ts +13 -0
- package/dist/timeline/message-timeline.d.ts +36 -0
- package/dist/timeline/model-picker.d.ts +17 -0
- package/dist/timeline/primitives.d.ts +85 -0
- package/dist/timeline/queue-dock.d.ts +19 -0
- package/dist/timeline/session-actions.d.ts +34 -0
- package/dist/timeline/session-list.d.ts +50 -0
- package/dist/timeline/session-screen.d.ts +35 -0
- package/dist/timeline/session-signals.d.ts +37 -0
- package/dist/timeline/sheet.d.ts +14 -0
- package/dist/timeline/theme.d.ts +58 -0
- package/dist/timeline/thinking-orb.d.ts +13 -0
- package/dist/timeline/turn.d.ts +80 -0
- package/dist/ui/attention.d.ts +14 -0
- package/dist/ui/cards.d.ts +21 -0
- package/dist/ui/composer.d.ts +21 -0
- package/dist/ui/format.d.ts +13 -0
- package/dist/ui/icons.d.ts +9 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +1757 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/steps-sheet.d.ts +10 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/timeline.d.ts +48 -0
- package/dist/ui/web-tokens.gen.d.ts +90 -0
- package/dist/use-native-session.d.ts +40 -0
- package/dist/view-model.d.ts +52 -0
- package/dist/voice-input.d.ts +102 -0
- package/dist/webrtc.d.ts +9 -0
- package/dist/webrtc.js +19 -0
- package/dist/webrtc.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/OpenGeniCall.podspec +22 -0
- package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
- package/ios/OpenGeniCallCenter.swift +189 -0
- package/ios/OpenGeniCallModule.swift +64 -0
- package/package.json +93 -0
- package/src/adapters.ts +150 -0
- package/src/attachments.ts +396 -0
- package/src/environment.tsx +214 -0
- package/src/expo.ts +295 -0
- package/src/index.ts +98 -0
- package/src/presentation.ts +285 -0
- package/src/read-acknowledgement.ts +72 -0
- package/src/realtime/call-events.ts +41 -0
- package/src/realtime/call-view.tsx +220 -0
- package/src/realtime/expo-call.ts +49 -0
- package/src/realtime/native-call.ts +266 -0
- package/src/realtime/native-realtime.ts +115 -0
- package/src/realtime/use-native-session-realtime.ts +29 -0
- package/src/session-view.tsx +2251 -0
- package/src/timeline/activity.tsx +736 -0
- package/src/timeline/composer.tsx +686 -0
- package/src/timeline/controls.tsx +151 -0
- package/src/timeline/decisions.tsx +651 -0
- package/src/timeline/feedback.tsx +251 -0
- package/src/timeline/icon-nodes.generated.ts +1830 -0
- package/src/timeline/icon.tsx +44 -0
- package/src/timeline/index.ts +67 -0
- package/src/timeline/markdown.tsx +510 -0
- package/src/timeline/message-attachments.tsx +155 -0
- package/src/timeline/message-timeline.tsx +1042 -0
- package/src/timeline/model-picker.tsx +450 -0
- package/src/timeline/primitives.tsx +561 -0
- package/src/timeline/queue-dock.tsx +331 -0
- package/src/timeline/session-actions.tsx +379 -0
- package/src/timeline/session-list.tsx +338 -0
- package/src/timeline/session-screen.tsx +488 -0
- package/src/timeline/session-signals.tsx +312 -0
- package/src/timeline/sheet.tsx +153 -0
- package/src/timeline/theme.tsx +128 -0
- package/src/timeline/thinking-orb.tsx +93 -0
- package/src/timeline/turn.tsx +569 -0
- package/src/ui/attention.tsx +220 -0
- package/src/ui/cards.tsx +267 -0
- package/src/ui/composer.tsx +296 -0
- package/src/ui/format.ts +86 -0
- package/src/ui/icons.tsx +131 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/steps-sheet.tsx +71 -0
- package/src/ui/theme.ts +261 -0
- package/src/ui/timeline.tsx +837 -0
- package/src/ui/web-tokens.gen.ts +94 -0
- package/src/use-native-session.ts +219 -0
- package/src/view-model.ts +485 -0
- package/src/voice-input.ts +382 -0
- package/src/webrtc.ts +30 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { ComposerDraft, SessionTurn } from "@opengeni/sdk";
|
|
2
|
+
import type { useTurnQueue } from "@opengeni/react/session";
|
|
3
|
+
import {
|
|
4
|
+
isAuthoritativeQueuedTurn,
|
|
5
|
+
QUEUE_ITEM_CONTENT_UNAVAILABLE,
|
|
6
|
+
QUEUE_REPLACE_DRAFT_COPY,
|
|
7
|
+
queueItemContent,
|
|
8
|
+
queuedTurnPresentation,
|
|
9
|
+
queueNeighborAnchors,
|
|
10
|
+
} from "@opengeni/react/queue-presentation-model";
|
|
11
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
12
|
+
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
|
13
|
+
import { Button } from "./controls";
|
|
14
|
+
import { Icon, type NativeIconName } from "./icon";
|
|
15
|
+
import { blendOver, withAlpha } from "./primitives";
|
|
16
|
+
import { fontStyle, useNativeTimelineTheme } from "./theme";
|
|
17
|
+
|
|
18
|
+
/* ----------------------------------------------------------------------------
|
|
19
|
+
The web session-chrome queue dock above the composer: a "N queued · Steer"
|
|
20
|
+
chip, then each waiting prompt on one line with Move up/down, Steer, Edit
|
|
21
|
+
(back into the composer, confirming before it replaces a draft) and Remove.
|
|
22
|
+
Which turns count, their text and the move anchors are the shared queue
|
|
23
|
+
model.
|
|
24
|
+
-------------------------------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
type TurnQueue = ReturnType<typeof useTurnQueue>;
|
|
27
|
+
|
|
28
|
+
/** The composer surface the dock checks prompts out into. */
|
|
29
|
+
export interface QueueDockComposer {
|
|
30
|
+
draftRevision: number;
|
|
31
|
+
hasDraftContent: () => boolean;
|
|
32
|
+
applyDraft: (draft: ComposerDraft) => void;
|
|
33
|
+
draftPersistence?: "durable" | "disabled" | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function QueueDock({
|
|
37
|
+
queue,
|
|
38
|
+
composer,
|
|
39
|
+
onComposerFocus,
|
|
40
|
+
leading,
|
|
41
|
+
}: {
|
|
42
|
+
queue: TurnQueue;
|
|
43
|
+
composer?: QueueDockComposer | undefined;
|
|
44
|
+
onComposerFocus?: (() => void) | undefined;
|
|
45
|
+
/** Chips sharing the queue chip's row (goal, agents, commands). */
|
|
46
|
+
leading?: ReactNode;
|
|
47
|
+
}) {
|
|
48
|
+
const theme = useNativeTimelineTheme();
|
|
49
|
+
const c = theme.colors;
|
|
50
|
+
const [open, setOpen] = useState(true);
|
|
51
|
+
const [replaceDraftFor, setReplaceDraftFor] = useState<string | null>(null);
|
|
52
|
+
const turns = useMemo(
|
|
53
|
+
() => queue.queue.filter((turn) => isAuthoritativeQueuedTurn(turn, queue.mutationFor)),
|
|
54
|
+
[queue.mutationFor, queue.queue],
|
|
55
|
+
);
|
|
56
|
+
if (turns.length === 0) {
|
|
57
|
+
return leading ? <View style={{ flexDirection: "row" }}>{leading}</View> : null;
|
|
58
|
+
}
|
|
59
|
+
const first = turns[0]!;
|
|
60
|
+
// The compact signal chip text (goal, agents and the queue share one row).
|
|
61
|
+
const small = { ...fontStyle(theme, 500), fontSize: 13, color: c.fg };
|
|
62
|
+
const canEdit = composer !== undefined && composer.draftPersistence !== "disabled";
|
|
63
|
+
const checkout = async (turnId: string, replaceDraft: boolean) => {
|
|
64
|
+
if (!composer) return;
|
|
65
|
+
const draft = await queue.editTurn(turnId, {
|
|
66
|
+
expectedDraftRevision: composer.draftRevision,
|
|
67
|
+
replaceDraft,
|
|
68
|
+
});
|
|
69
|
+
if (draft) {
|
|
70
|
+
composer.applyDraft(draft);
|
|
71
|
+
onComposerFocus?.();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const edit = (turn: SessionTurn) => {
|
|
75
|
+
if (!composer) return;
|
|
76
|
+
if (composer.hasDraftContent()) setReplaceDraftFor(turn.id);
|
|
77
|
+
else void checkout(turn.id, false);
|
|
78
|
+
};
|
|
79
|
+
return (
|
|
80
|
+
<View style={{ gap: 6 }}>
|
|
81
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
82
|
+
{leading}
|
|
83
|
+
<View
|
|
84
|
+
style={{
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
height: 30,
|
|
88
|
+
borderRadius: 15,
|
|
89
|
+
backgroundColor: c["surface-2"],
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<Pressable
|
|
93
|
+
accessibilityRole="button"
|
|
94
|
+
accessibilityState={{ expanded: open }}
|
|
95
|
+
accessibilityLabel={`${turns.length} queued prompt${turns.length === 1 ? "" : "s"}`}
|
|
96
|
+
onPress={() => setOpen((value) => !value)}
|
|
97
|
+
hitSlop={{ top: 7, bottom: 7 }}
|
|
98
|
+
style={{
|
|
99
|
+
flexDirection: "row",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
gap: 5,
|
|
102
|
+
height: 30,
|
|
103
|
+
paddingLeft: 10,
|
|
104
|
+
paddingRight: 6,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<Icon name="list-ordered" size={13} color={c["fg-muted"]} />
|
|
108
|
+
<Text style={small}>{`${turns.length} queued`}</Text>
|
|
109
|
+
</Pressable>
|
|
110
|
+
<View style={{ width: 1, height: 14, backgroundColor: c.border }} />
|
|
111
|
+
<Pressable
|
|
112
|
+
accessibilityRole="button"
|
|
113
|
+
accessibilityLabel="Steer first queued message"
|
|
114
|
+
disabled={queue.mutating || queue.mutationFor(first.id) !== null}
|
|
115
|
+
onPress={() => void queue.steerTurn(first.id)}
|
|
116
|
+
hitSlop={{ top: 7, bottom: 7 }}
|
|
117
|
+
style={{
|
|
118
|
+
flexDirection: "row",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
gap: 4,
|
|
121
|
+
height: 30,
|
|
122
|
+
paddingLeft: 6,
|
|
123
|
+
paddingRight: 10,
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<Icon name="corner-down-right" size={13} color={c["fg-muted"]} />
|
|
127
|
+
<Text style={small}>Steer</Text>
|
|
128
|
+
</Pressable>
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
{open ? (
|
|
132
|
+
<View
|
|
133
|
+
accessibilityLabel="Queued prompts"
|
|
134
|
+
style={{
|
|
135
|
+
borderRadius: theme.radius.lg,
|
|
136
|
+
// Opaque: the dock floats over scrolling conversation.
|
|
137
|
+
backgroundColor: blendOver(c.bg, c["surface-2"], 0.6),
|
|
138
|
+
paddingHorizontal: 8,
|
|
139
|
+
paddingVertical: 2,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{turns.map((turn, index) => {
|
|
143
|
+
const presentation = queuedTurnPresentation(turn);
|
|
144
|
+
const voice = presentation.kind !== "prompt";
|
|
145
|
+
const pending = queue.mutationFor(turn.id);
|
|
146
|
+
const busy = pending !== null;
|
|
147
|
+
const { beforeUp, beforeDown } = queueNeighborAnchors(turns, index);
|
|
148
|
+
const annotationCount = turn.annotations?.length ?? 0;
|
|
149
|
+
const content = queueItemContent(presentation.text, annotationCount);
|
|
150
|
+
const label =
|
|
151
|
+
content === "text"
|
|
152
|
+
? presentation.text
|
|
153
|
+
: content === "annotations"
|
|
154
|
+
? `${annotationCount} timeline annotation${annotationCount === 1 ? "" : "s"}`
|
|
155
|
+
: QUEUE_ITEM_CONTENT_UNAVAILABLE;
|
|
156
|
+
return (
|
|
157
|
+
<View key={turn.id} style={{ gap: 4 }}>
|
|
158
|
+
<View
|
|
159
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingLeft: 6 }}
|
|
160
|
+
>
|
|
161
|
+
{voice ? (
|
|
162
|
+
<Icon name="audio-lines" size={12} color={c.accent} />
|
|
163
|
+
) : (
|
|
164
|
+
<Text
|
|
165
|
+
style={{
|
|
166
|
+
...fontStyle(theme, 400, "mono"),
|
|
167
|
+
fontSize: 10,
|
|
168
|
+
color: c["fg-subtle"],
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{index + 1}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
<Text
|
|
175
|
+
numberOfLines={1}
|
|
176
|
+
style={{
|
|
177
|
+
...fontStyle(theme),
|
|
178
|
+
flex: 1,
|
|
179
|
+
fontSize: theme.size.xs,
|
|
180
|
+
lineHeight: 16,
|
|
181
|
+
color: content === "unavailable" ? c["fg-muted"] : c.fg,
|
|
182
|
+
fontStyle: content === "unavailable" ? "italic" : "normal",
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{label}
|
|
186
|
+
</Text>
|
|
187
|
+
{turns.length > 1 ? (
|
|
188
|
+
<>
|
|
189
|
+
<RowAction
|
|
190
|
+
icon="arrow-up"
|
|
191
|
+
label={`Move queued prompt ${index + 1} up`}
|
|
192
|
+
disabled={busy || index === 0}
|
|
193
|
+
onPress={() => void queue.moveTurn(turn.id, beforeUp)}
|
|
194
|
+
/>
|
|
195
|
+
<RowAction
|
|
196
|
+
icon="arrow-down"
|
|
197
|
+
label={`Move queued prompt ${index + 1} down`}
|
|
198
|
+
disabled={busy || index >= turns.length - 1}
|
|
199
|
+
onPress={() => void queue.moveTurn(turn.id, beforeDown)}
|
|
200
|
+
/>
|
|
201
|
+
</>
|
|
202
|
+
) : null}
|
|
203
|
+
<RowAction
|
|
204
|
+
icon="corner-down-right"
|
|
205
|
+
label={`Steer queued prompt ${index + 1}`}
|
|
206
|
+
disabled={busy}
|
|
207
|
+
busy={pending === "steer"}
|
|
208
|
+
onPress={() => void queue.steerTurn(turn.id)}
|
|
209
|
+
/>
|
|
210
|
+
{canEdit ? (
|
|
211
|
+
<RowAction
|
|
212
|
+
icon="pencil"
|
|
213
|
+
label={`Edit queued prompt ${index + 1}`}
|
|
214
|
+
disabled={busy}
|
|
215
|
+
busy={pending === "edit"}
|
|
216
|
+
onPress={() => edit(turn)}
|
|
217
|
+
/>
|
|
218
|
+
) : null}
|
|
219
|
+
<RowAction
|
|
220
|
+
icon="trash-2"
|
|
221
|
+
label={`Remove queued prompt ${index + 1}`}
|
|
222
|
+
disabled={busy}
|
|
223
|
+
busy={pending === "delete"}
|
|
224
|
+
onPress={() => void queue.removeTurn(turn.id)}
|
|
225
|
+
/>
|
|
226
|
+
</View>
|
|
227
|
+
{replaceDraftFor === turn.id ? (
|
|
228
|
+
<View
|
|
229
|
+
style={{
|
|
230
|
+
gap: 4,
|
|
231
|
+
padding: 8,
|
|
232
|
+
marginBottom: 4,
|
|
233
|
+
borderRadius: theme.radius.sm,
|
|
234
|
+
borderWidth: 1,
|
|
235
|
+
borderColor: withAlpha(c["status-waiting"], 0.3),
|
|
236
|
+
backgroundColor: withAlpha(c["status-waiting"], 0.1),
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<Text style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c.fg }}>
|
|
240
|
+
{QUEUE_REPLACE_DRAFT_COPY.title}
|
|
241
|
+
</Text>
|
|
242
|
+
<Text
|
|
243
|
+
style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c["fg-muted"] }}
|
|
244
|
+
>
|
|
245
|
+
{QUEUE_REPLACE_DRAFT_COPY.detail}
|
|
246
|
+
</Text>
|
|
247
|
+
<View
|
|
248
|
+
style={{
|
|
249
|
+
flexDirection: "row",
|
|
250
|
+
justifyContent: "flex-end",
|
|
251
|
+
gap: 6,
|
|
252
|
+
marginTop: 4,
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<Button
|
|
256
|
+
label={QUEUE_REPLACE_DRAFT_COPY.keep}
|
|
257
|
+
variant="ghost"
|
|
258
|
+
onPress={() => setReplaceDraftFor(null)}
|
|
259
|
+
/>
|
|
260
|
+
<Button
|
|
261
|
+
label={QUEUE_REPLACE_DRAFT_COPY.replace}
|
|
262
|
+
variant="primary"
|
|
263
|
+
onPress={() => {
|
|
264
|
+
setReplaceDraftFor(null);
|
|
265
|
+
void checkout(turn.id, true);
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
</View>
|
|
269
|
+
</View>
|
|
270
|
+
) : null}
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
})}
|
|
274
|
+
</View>
|
|
275
|
+
) : null}
|
|
276
|
+
{queue.mutationError ? (
|
|
277
|
+
<Pressable accessibilityRole="alert" onPress={queue.clearMutationError}>
|
|
278
|
+
<Text style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c["status-failed"] }}>
|
|
279
|
+
{queue.mutationError.message}
|
|
280
|
+
</Text>
|
|
281
|
+
</Pressable>
|
|
282
|
+
) : null}
|
|
283
|
+
</View>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A web IconAction at touch size: icon, optional text, a spinner while busy. */
|
|
288
|
+
function RowAction(props: {
|
|
289
|
+
icon: NativeIconName;
|
|
290
|
+
label: string;
|
|
291
|
+
text?: string | undefined;
|
|
292
|
+
disabled?: boolean | undefined;
|
|
293
|
+
busy?: boolean | undefined;
|
|
294
|
+
onPress: () => void;
|
|
295
|
+
}) {
|
|
296
|
+
const theme = useNativeTimelineTheme();
|
|
297
|
+
const c = theme.colors;
|
|
298
|
+
return (
|
|
299
|
+
<Pressable
|
|
300
|
+
accessibilityRole="button"
|
|
301
|
+
accessibilityLabel={props.label}
|
|
302
|
+
accessibilityState={{ disabled: Boolean(props.disabled), busy: Boolean(props.busy) }}
|
|
303
|
+
disabled={props.disabled}
|
|
304
|
+
onPress={props.onPress}
|
|
305
|
+
hitSlop={{ top: 6, bottom: 6, left: 2, right: 2 }}
|
|
306
|
+
style={({ pressed }) => ({
|
|
307
|
+
flexDirection: "row",
|
|
308
|
+
alignItems: "center",
|
|
309
|
+
justifyContent: "center",
|
|
310
|
+
gap: 4,
|
|
311
|
+
minWidth: 30,
|
|
312
|
+
height: 32,
|
|
313
|
+
paddingHorizontal: props.text ? 6 : 0,
|
|
314
|
+
borderRadius: theme.radius.sm,
|
|
315
|
+
backgroundColor: pressed ? c.hover : "transparent",
|
|
316
|
+
opacity: props.disabled && !props.busy ? 0.4 : 1,
|
|
317
|
+
})}
|
|
318
|
+
>
|
|
319
|
+
{props.busy ? (
|
|
320
|
+
<ActivityIndicator size="small" color={c["fg-muted"]} />
|
|
321
|
+
) : (
|
|
322
|
+
<Icon name={props.icon} size={14} color={c.fg} />
|
|
323
|
+
)}
|
|
324
|
+
{props.text ? (
|
|
325
|
+
<Text style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c.fg }}>
|
|
326
|
+
{props.text}
|
|
327
|
+
</Text>
|
|
328
|
+
) : null}
|
|
329
|
+
</Pressable>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type { Session, UpdateSessionPinRequest, UpdateSessionRequest } from "@opengeni/sdk";
|
|
2
|
+
import {
|
|
3
|
+
resolveRenameSubmission,
|
|
4
|
+
SESSION_TITLE_MAX_LENGTH,
|
|
5
|
+
sessionDisplayTitle,
|
|
6
|
+
sessionRenameSeed,
|
|
7
|
+
} from "@opengeni/react/session-list-model";
|
|
8
|
+
import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from "react";
|
|
9
|
+
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
|
10
|
+
import { Button } from "./controls";
|
|
11
|
+
import { Icon, type NativeIconName } from "./icon";
|
|
12
|
+
import { BottomSheet } from "./sheet";
|
|
13
|
+
import { fontStyle, useNativeTimelineTheme } from "./theme";
|
|
14
|
+
|
|
15
|
+
/* ----------------------------------------------------------------------------
|
|
16
|
+
The web session header's Rename and Pin, behind one native overflow button:
|
|
17
|
+
an action sheet, then a rename sheet that seeds and resolves exactly as the
|
|
18
|
+
web title editor does (shared session-list model).
|
|
19
|
+
-------------------------------------------------------------------------- */
|
|
20
|
+
|
|
21
|
+
export interface SessionActionsClient {
|
|
22
|
+
updateSession(
|
|
23
|
+
workspaceId: string,
|
|
24
|
+
sessionId: string,
|
|
25
|
+
request: UpdateSessionRequest,
|
|
26
|
+
): Promise<Session>;
|
|
27
|
+
updateSessionPin(
|
|
28
|
+
workspaceId: string,
|
|
29
|
+
sessionId: string,
|
|
30
|
+
request: UpdateSessionPinRequest,
|
|
31
|
+
): Promise<Session>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionActionsProps {
|
|
35
|
+
session: Session;
|
|
36
|
+
client: SessionActionsClient;
|
|
37
|
+
/** Called with the server's session after a change, so the host can refresh. */
|
|
38
|
+
onChanged: (session: Session) => void;
|
|
39
|
+
/** Host actions appended after Rename and Pin. */
|
|
40
|
+
extraActions?: SessionAction[] | undefined;
|
|
41
|
+
onActionFeedback?: (() => void) | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Present the actions in a host-native menu (a UIMenu on iOS) anchored to the
|
|
44
|
+
* trigger instead of the slide-up sheet. The host wraps `trigger`.
|
|
45
|
+
*/
|
|
46
|
+
renderMenu?: ((input: { actions: SessionAction[]; trigger: ReactNode }) => ReactNode) | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SessionAction {
|
|
50
|
+
key: string;
|
|
51
|
+
label: string;
|
|
52
|
+
icon: NativeIconName;
|
|
53
|
+
onPress: () => void;
|
|
54
|
+
destructive?: boolean | undefined;
|
|
55
|
+
/** SF Symbol for a native menu. */
|
|
56
|
+
systemImage?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function SessionActionsButton(props: SessionActionsProps) {
|
|
60
|
+
const theme = useNativeTimelineTheme();
|
|
61
|
+
const c = theme.colors;
|
|
62
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
63
|
+
const [renaming, setRenaming] = useState(false);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
// The pin flips immediately on this device and rolls back on failure, as on web.
|
|
66
|
+
const [pinnedOverride, setPinnedOverride] = useState<boolean | null>(null);
|
|
67
|
+
const pinned = pinnedOverride ?? props.session.pinned ?? false;
|
|
68
|
+
useEffect(() => setPinnedOverride(null), [props.session.pinned, props.session.pinVersion]);
|
|
69
|
+
|
|
70
|
+
const togglePin = async () => {
|
|
71
|
+
const next = !pinned;
|
|
72
|
+
setPinnedOverride(next);
|
|
73
|
+
setError(null);
|
|
74
|
+
props.onActionFeedback?.();
|
|
75
|
+
try {
|
|
76
|
+
const updated = await props.client.updateSessionPin(
|
|
77
|
+
props.session.workspaceId,
|
|
78
|
+
props.session.id,
|
|
79
|
+
{
|
|
80
|
+
pinned: next,
|
|
81
|
+
...(props.session.pinVersion !== undefined
|
|
82
|
+
? { expectedVersion: props.session.pinVersion }
|
|
83
|
+
: {}),
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
props.onChanged(updated);
|
|
87
|
+
} catch (caught) {
|
|
88
|
+
setPinnedOverride(null);
|
|
89
|
+
const message =
|
|
90
|
+
`Couldn't ${next ? "pin" : "unpin"} this chat. ${caught instanceof Error ? caught.message : ""}`.trim();
|
|
91
|
+
setError(message);
|
|
92
|
+
if (props.renderMenu) Alert.alert(message);
|
|
93
|
+
else setMenuOpen(true);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const actions: SessionAction[] = [
|
|
98
|
+
{
|
|
99
|
+
key: "rename",
|
|
100
|
+
label: "Rename",
|
|
101
|
+
icon: "pencil",
|
|
102
|
+
systemImage: "pencil",
|
|
103
|
+
onPress: () => {
|
|
104
|
+
setMenuOpen(false);
|
|
105
|
+
// Let a dismissing menu or sheet finish before presenting the next one.
|
|
106
|
+
setTimeout(() => setRenaming(true), props.renderMenu ? 50 : 350);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: "pin",
|
|
111
|
+
label: pinned ? "Unpin" : "Pin",
|
|
112
|
+
icon: pinned ? "pin-off" : "pin",
|
|
113
|
+
systemImage: pinned ? "pin.slash" : "pin",
|
|
114
|
+
onPress: () => {
|
|
115
|
+
setMenuOpen(false);
|
|
116
|
+
void togglePin();
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
...(props.extraActions ?? []).map((action) => ({
|
|
120
|
+
...action,
|
|
121
|
+
onPress: () => {
|
|
122
|
+
setMenuOpen(false);
|
|
123
|
+
action.onPress();
|
|
124
|
+
},
|
|
125
|
+
})),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const icon = (
|
|
129
|
+
<>
|
|
130
|
+
{pinned ? (
|
|
131
|
+
<View
|
|
132
|
+
style={{
|
|
133
|
+
position: "absolute",
|
|
134
|
+
top: 5,
|
|
135
|
+
right: 5,
|
|
136
|
+
width: 6,
|
|
137
|
+
height: 6,
|
|
138
|
+
borderRadius: 3,
|
|
139
|
+
backgroundColor: c["fg-muted"],
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
) : null}
|
|
143
|
+
<Icon name="ellipsis" size={20} color={c.fg} />
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
const renameSheet = (
|
|
147
|
+
<RenameSheet
|
|
148
|
+
open={renaming}
|
|
149
|
+
session={props.session}
|
|
150
|
+
client={props.client}
|
|
151
|
+
onClose={() => setRenaming(false)}
|
|
152
|
+
onRenamed={(session) => {
|
|
153
|
+
setRenaming(false);
|
|
154
|
+
props.onActionFeedback?.();
|
|
155
|
+
props.onChanged(session);
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
if (props.renderMenu) {
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
{props.renderMenu({
|
|
163
|
+
actions,
|
|
164
|
+
trigger: (
|
|
165
|
+
<View
|
|
166
|
+
accessible
|
|
167
|
+
accessibilityRole="button"
|
|
168
|
+
accessibilityLabel="Chat actions"
|
|
169
|
+
style={{ width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
|
|
170
|
+
>
|
|
171
|
+
{icon}
|
|
172
|
+
</View>
|
|
173
|
+
),
|
|
174
|
+
})}
|
|
175
|
+
{renameSheet}
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
<Pressable
|
|
182
|
+
accessibilityRole="button"
|
|
183
|
+
accessibilityLabel="Chat actions"
|
|
184
|
+
hitSlop={6}
|
|
185
|
+
onPress={() => setMenuOpen(true)}
|
|
186
|
+
style={({ pressed }) => ({
|
|
187
|
+
width: 36,
|
|
188
|
+
height: 36,
|
|
189
|
+
alignItems: "center",
|
|
190
|
+
justifyContent: "center",
|
|
191
|
+
borderRadius: 18,
|
|
192
|
+
backgroundColor: pressed ? c.hover : "transparent",
|
|
193
|
+
})}
|
|
194
|
+
>
|
|
195
|
+
{icon}
|
|
196
|
+
</Pressable>
|
|
197
|
+
<BottomSheet
|
|
198
|
+
open={menuOpen}
|
|
199
|
+
onClose={() => setMenuOpen(false)}
|
|
200
|
+
accessibilityLabel="Chat actions"
|
|
201
|
+
>
|
|
202
|
+
<Text
|
|
203
|
+
numberOfLines={1}
|
|
204
|
+
style={{
|
|
205
|
+
...fontStyle(theme, 600),
|
|
206
|
+
fontSize: 15,
|
|
207
|
+
color: c.fg,
|
|
208
|
+
paddingHorizontal: 20,
|
|
209
|
+
paddingBottom: 8,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{sessionDisplayTitle(props.session)}
|
|
213
|
+
</Text>
|
|
214
|
+
{error ? (
|
|
215
|
+
<Text
|
|
216
|
+
accessibilityRole="alert"
|
|
217
|
+
style={{
|
|
218
|
+
...fontStyle(theme),
|
|
219
|
+
fontSize: 13,
|
|
220
|
+
color: c["status-failed"],
|
|
221
|
+
paddingHorizontal: 20,
|
|
222
|
+
paddingBottom: 6,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{error}
|
|
226
|
+
</Text>
|
|
227
|
+
) : null}
|
|
228
|
+
{actions.map((action) => (
|
|
229
|
+
<Pressable
|
|
230
|
+
key={action.key}
|
|
231
|
+
accessibilityRole="button"
|
|
232
|
+
accessibilityLabel={action.label}
|
|
233
|
+
onPress={action.onPress}
|
|
234
|
+
style={({ pressed }) => ({
|
|
235
|
+
flexDirection: "row",
|
|
236
|
+
alignItems: "center",
|
|
237
|
+
gap: 14,
|
|
238
|
+
minHeight: 52,
|
|
239
|
+
paddingHorizontal: 20,
|
|
240
|
+
backgroundColor: pressed ? c.hover : "transparent",
|
|
241
|
+
})}
|
|
242
|
+
>
|
|
243
|
+
<Icon
|
|
244
|
+
name={action.icon}
|
|
245
|
+
size={18}
|
|
246
|
+
color={action.destructive ? c["status-failed"] : c["fg-muted"]}
|
|
247
|
+
/>
|
|
248
|
+
<Text
|
|
249
|
+
style={{
|
|
250
|
+
...fontStyle(theme),
|
|
251
|
+
fontSize: 16,
|
|
252
|
+
color: action.destructive ? c["status-failed"] : c.fg,
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{action.label}
|
|
256
|
+
</Text>
|
|
257
|
+
</Pressable>
|
|
258
|
+
))}
|
|
259
|
+
</BottomSheet>
|
|
260
|
+
{renameSheet}
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function RenameSheet(props: {
|
|
266
|
+
open: boolean;
|
|
267
|
+
session: Session;
|
|
268
|
+
client: SessionActionsClient;
|
|
269
|
+
onClose: () => void;
|
|
270
|
+
onRenamed: (session: Session) => void;
|
|
271
|
+
}) {
|
|
272
|
+
// Each opening mounts a fresh form seeded before its first render, so the
|
|
273
|
+
// focused field selects the whole seed and typing replaces it.
|
|
274
|
+
const [generation, setGeneration] = useState(0);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (props.open) setGeneration((value) => value + 1);
|
|
277
|
+
}, [props.open]);
|
|
278
|
+
return (
|
|
279
|
+
<BottomSheet open={props.open} onClose={props.onClose} accessibilityLabel="Rename chat">
|
|
280
|
+
<RenameForm key={generation} {...props} />
|
|
281
|
+
</BottomSheet>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function RenameForm(props: {
|
|
286
|
+
session: Session;
|
|
287
|
+
client: SessionActionsClient;
|
|
288
|
+
onClose: () => void;
|
|
289
|
+
onRenamed: (session: Session) => void;
|
|
290
|
+
}) {
|
|
291
|
+
const theme = useNativeTimelineTheme();
|
|
292
|
+
const c = theme.colors;
|
|
293
|
+
const [seed] = useState(() => sessionRenameSeed(props.session));
|
|
294
|
+
const [draft, setDraft] = useState(seed);
|
|
295
|
+
const [saving, setSaving] = useState(false);
|
|
296
|
+
const [error, setError] = useState<string | null>(null);
|
|
297
|
+
const inFlight = useRef(false);
|
|
298
|
+
const inputRef = useRef<ComponentRef<typeof TextInput>>(null);
|
|
299
|
+
const save = async () => {
|
|
300
|
+
if (inFlight.current) return;
|
|
301
|
+
const next = resolveRenameSubmission(draft, sessionDisplayTitle(props.session), seed);
|
|
302
|
+
if (next === null) {
|
|
303
|
+
props.onClose();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
inFlight.current = true;
|
|
307
|
+
setSaving(true);
|
|
308
|
+
setError(null);
|
|
309
|
+
try {
|
|
310
|
+
const updated = await props.client.updateSession(
|
|
311
|
+
props.session.workspaceId,
|
|
312
|
+
props.session.id,
|
|
313
|
+
{ title: next },
|
|
314
|
+
);
|
|
315
|
+
props.onRenamed(updated);
|
|
316
|
+
} catch (caught) {
|
|
317
|
+
setError(
|
|
318
|
+
`Couldn't rename this chat. ${caught instanceof Error ? caught.message : "Try again."}`,
|
|
319
|
+
);
|
|
320
|
+
} finally {
|
|
321
|
+
inFlight.current = false;
|
|
322
|
+
setSaving(false);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
return (
|
|
326
|
+
<View style={{ paddingHorizontal: 20, gap: 12 }}>
|
|
327
|
+
<Text
|
|
328
|
+
accessibilityRole="header"
|
|
329
|
+
style={{ ...fontStyle(theme, 600), fontSize: 17, color: c.fg }}
|
|
330
|
+
>
|
|
331
|
+
Rename chat
|
|
332
|
+
</Text>
|
|
333
|
+
<TextInput
|
|
334
|
+
accessibilityLabel="Chat title"
|
|
335
|
+
value={draft}
|
|
336
|
+
onChangeText={setDraft}
|
|
337
|
+
ref={inputRef}
|
|
338
|
+
onFocus={() => {
|
|
339
|
+
// selectTextOnFocus is unreliable for autofocused fields in a modal.
|
|
340
|
+
setTimeout(() => inputRef.current?.setSelection(0, seed.length), 50);
|
|
341
|
+
}}
|
|
342
|
+
maxLength={SESSION_TITLE_MAX_LENGTH}
|
|
343
|
+
autoFocus
|
|
344
|
+
editable={!saving}
|
|
345
|
+
returnKeyType="done"
|
|
346
|
+
onSubmitEditing={() => void save()}
|
|
347
|
+
style={{
|
|
348
|
+
...fontStyle(theme),
|
|
349
|
+
fontSize: 16,
|
|
350
|
+
color: c.fg,
|
|
351
|
+
minHeight: 44,
|
|
352
|
+
paddingHorizontal: 12,
|
|
353
|
+
borderRadius: theme.radius.md,
|
|
354
|
+
borderWidth: 1,
|
|
355
|
+
borderColor: c.border,
|
|
356
|
+
backgroundColor: c["surface-1"],
|
|
357
|
+
}}
|
|
358
|
+
/>
|
|
359
|
+
{error ? (
|
|
360
|
+
<Text
|
|
361
|
+
accessibilityRole="alert"
|
|
362
|
+
style={{ ...fontStyle(theme), fontSize: 13, color: c["status-failed"] }}
|
|
363
|
+
>
|
|
364
|
+
{error}
|
|
365
|
+
</Text>
|
|
366
|
+
) : null}
|
|
367
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
|
|
368
|
+
<Button label="Cancel" variant="ghost" onPress={props.onClose} disabled={saving} />
|
|
369
|
+
<Button
|
|
370
|
+
label={saving ? "Saving…" : "Save"}
|
|
371
|
+
variant="primary"
|
|
372
|
+
onPress={() => void save()}
|
|
373
|
+
disabled={saving}
|
|
374
|
+
busy={saving}
|
|
375
|
+
/>
|
|
376
|
+
</View>
|
|
377
|
+
</View>
|
|
378
|
+
);
|
|
379
|
+
}
|