@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.
Files changed (132) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +74 -0
  3. package/dist/adapters.d.ts +53 -0
  4. package/dist/adapters.js +3 -0
  5. package/dist/adapters.js.map +1 -0
  6. package/dist/attachments.d.ts +75 -0
  7. package/dist/chunk-3VSZAQ7G.js +391 -0
  8. package/dist/chunk-3VSZAQ7G.js.map +1 -0
  9. package/dist/chunk-5ZVK7WKA.js +88 -0
  10. package/dist/chunk-5ZVK7WKA.js.map +1 -0
  11. package/dist/chunk-EEDIMY3L.js +240 -0
  12. package/dist/chunk-EEDIMY3L.js.map +1 -0
  13. package/dist/chunk-M23SDI4R.js +83 -0
  14. package/dist/chunk-M23SDI4R.js.map +1 -0
  15. package/dist/chunk-PHJBSAR3.js +2006 -0
  16. package/dist/chunk-PHJBSAR3.js.map +1 -0
  17. package/dist/environment.d.ts +19 -0
  18. package/dist/expo.d.ts +25 -0
  19. package/dist/expo.js +267 -0
  20. package/dist/expo.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +3345 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/presentation.d.ts +262 -0
  25. package/dist/read-acknowledgement.d.ts +39 -0
  26. package/dist/realtime/call-events.d.ts +11 -0
  27. package/dist/realtime/call-view.d.ts +26 -0
  28. package/dist/realtime/expo-call.d.ts +11 -0
  29. package/dist/realtime/native-call.d.ts +104 -0
  30. package/dist/realtime/native-realtime.d.ts +35 -0
  31. package/dist/realtime/use-native-session-realtime.d.ts +25 -0
  32. package/dist/session-view.d.ts +87 -0
  33. package/dist/timeline/activity.d.ts +34 -0
  34. package/dist/timeline/composer.d.ts +86 -0
  35. package/dist/timeline/controls.d.ts +27 -0
  36. package/dist/timeline/decisions.d.ts +29 -0
  37. package/dist/timeline/feedback.d.ts +26 -0
  38. package/dist/timeline/icon-nodes.generated.d.ts +738 -0
  39. package/dist/timeline/icon.d.ts +11 -0
  40. package/dist/timeline/index.d.ts +32 -0
  41. package/dist/timeline/index.js +5419 -0
  42. package/dist/timeline/index.js.map +1 -0
  43. package/dist/timeline/markdown.d.ts +11 -0
  44. package/dist/timeline/markdown.js +415 -0
  45. package/dist/timeline/markdown.js.map +1 -0
  46. package/dist/timeline/message-attachments.d.ts +13 -0
  47. package/dist/timeline/message-timeline.d.ts +36 -0
  48. package/dist/timeline/model-picker.d.ts +17 -0
  49. package/dist/timeline/primitives.d.ts +85 -0
  50. package/dist/timeline/queue-dock.d.ts +19 -0
  51. package/dist/timeline/session-actions.d.ts +34 -0
  52. package/dist/timeline/session-list.d.ts +50 -0
  53. package/dist/timeline/session-screen.d.ts +35 -0
  54. package/dist/timeline/session-signals.d.ts +37 -0
  55. package/dist/timeline/sheet.d.ts +14 -0
  56. package/dist/timeline/theme.d.ts +58 -0
  57. package/dist/timeline/thinking-orb.d.ts +13 -0
  58. package/dist/timeline/turn.d.ts +80 -0
  59. package/dist/ui/attention.d.ts +14 -0
  60. package/dist/ui/cards.d.ts +21 -0
  61. package/dist/ui/composer.d.ts +21 -0
  62. package/dist/ui/format.d.ts +13 -0
  63. package/dist/ui/icons.d.ts +9 -0
  64. package/dist/ui/index.d.ts +12 -0
  65. package/dist/ui/index.js +1757 -0
  66. package/dist/ui/index.js.map +1 -0
  67. package/dist/ui/steps-sheet.d.ts +10 -0
  68. package/dist/ui/theme.d.ts +63 -0
  69. package/dist/ui/timeline.d.ts +48 -0
  70. package/dist/ui/web-tokens.gen.d.ts +90 -0
  71. package/dist/use-native-session.d.ts +40 -0
  72. package/dist/view-model.d.ts +52 -0
  73. package/dist/voice-input.d.ts +102 -0
  74. package/dist/webrtc.d.ts +9 -0
  75. package/dist/webrtc.js +19 -0
  76. package/dist/webrtc.js.map +1 -0
  77. package/expo-module.config.json +7 -0
  78. package/ios/OpenGeniCall.podspec +22 -0
  79. package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
  80. package/ios/OpenGeniCallCenter.swift +189 -0
  81. package/ios/OpenGeniCallModule.swift +64 -0
  82. package/package.json +93 -0
  83. package/src/adapters.ts +150 -0
  84. package/src/attachments.ts +396 -0
  85. package/src/environment.tsx +214 -0
  86. package/src/expo.ts +295 -0
  87. package/src/index.ts +98 -0
  88. package/src/presentation.ts +285 -0
  89. package/src/read-acknowledgement.ts +72 -0
  90. package/src/realtime/call-events.ts +41 -0
  91. package/src/realtime/call-view.tsx +220 -0
  92. package/src/realtime/expo-call.ts +49 -0
  93. package/src/realtime/native-call.ts +266 -0
  94. package/src/realtime/native-realtime.ts +115 -0
  95. package/src/realtime/use-native-session-realtime.ts +29 -0
  96. package/src/session-view.tsx +2251 -0
  97. package/src/timeline/activity.tsx +736 -0
  98. package/src/timeline/composer.tsx +686 -0
  99. package/src/timeline/controls.tsx +151 -0
  100. package/src/timeline/decisions.tsx +651 -0
  101. package/src/timeline/feedback.tsx +251 -0
  102. package/src/timeline/icon-nodes.generated.ts +1830 -0
  103. package/src/timeline/icon.tsx +44 -0
  104. package/src/timeline/index.ts +67 -0
  105. package/src/timeline/markdown.tsx +510 -0
  106. package/src/timeline/message-attachments.tsx +155 -0
  107. package/src/timeline/message-timeline.tsx +1042 -0
  108. package/src/timeline/model-picker.tsx +450 -0
  109. package/src/timeline/primitives.tsx +561 -0
  110. package/src/timeline/queue-dock.tsx +331 -0
  111. package/src/timeline/session-actions.tsx +379 -0
  112. package/src/timeline/session-list.tsx +338 -0
  113. package/src/timeline/session-screen.tsx +488 -0
  114. package/src/timeline/session-signals.tsx +312 -0
  115. package/src/timeline/sheet.tsx +153 -0
  116. package/src/timeline/theme.tsx +128 -0
  117. package/src/timeline/thinking-orb.tsx +93 -0
  118. package/src/timeline/turn.tsx +569 -0
  119. package/src/ui/attention.tsx +220 -0
  120. package/src/ui/cards.tsx +267 -0
  121. package/src/ui/composer.tsx +296 -0
  122. package/src/ui/format.ts +86 -0
  123. package/src/ui/icons.tsx +131 -0
  124. package/src/ui/index.ts +18 -0
  125. package/src/ui/steps-sheet.tsx +71 -0
  126. package/src/ui/theme.ts +261 -0
  127. package/src/ui/timeline.tsx +837 -0
  128. package/src/ui/web-tokens.gen.ts +94 -0
  129. package/src/use-native-session.ts +219 -0
  130. package/src/view-model.ts +485 -0
  131. package/src/voice-input.ts +382 -0
  132. 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
+ }