@alia.onl/sdk 15.8.0 → 19.0.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.
Files changed (141) hide show
  1. package/CHANGELOG.md +242 -13
  2. package/README.md +223 -151
  3. package/package.json +15 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +0 -3
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +76 -34
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +32 -14
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +123 -0
  91. package/src/runtime/attachment-utils.ts +96 -0
  92. package/src/runtime/composer-draft-store.ts +270 -0
  93. package/src/runtime/config.tsx +186 -0
  94. package/src/runtime/conversation-store.ts +49 -0
  95. package/src/runtime/conversations.ts +557 -0
  96. package/src/runtime/error-utils.ts +104 -0
  97. package/src/runtime/host-tools.ts +140 -0
  98. package/src/runtime/index.ts +113 -0
  99. package/src/runtime/message-history.ts +124 -0
  100. package/src/runtime/model.ts +39 -0
  101. package/src/runtime/oxy-files.ts +37 -0
  102. package/src/runtime/power-levels.ts +66 -0
  103. package/src/runtime/query-keys.ts +34 -0
  104. package/src/runtime/random-uuid.ts +25 -0
  105. package/src/runtime/routes.ts +51 -0
  106. package/src/runtime/sharing.ts +165 -0
  107. package/src/runtime/sse-frame-reader.ts +88 -0
  108. package/src/runtime/stream-events.ts +146 -0
  109. package/src/runtime/stream-outcome.ts +21 -0
  110. package/src/runtime/thought-store.ts +103 -0
  111. package/src/runtime/thread/agent-activity.ts +261 -0
  112. package/src/runtime/thread/automation-labels.ts +79 -0
  113. package/src/runtime/thread/conversation-export.ts +135 -0
  114. package/src/runtime/thread/message-days.ts +120 -0
  115. package/src/runtime/thread/message-processor.ts +93 -0
  116. package/src/runtime/thread/task-utils.ts +11 -0
  117. package/src/runtime/thread/thought-utils.ts +752 -0
  118. package/src/runtime/thread/thread-history.ts +139 -0
  119. package/src/runtime/thread/timeline.ts +202 -0
  120. package/src/runtime/thread/title-tags.ts +8 -0
  121. package/src/runtime/thread/tool-cards.ts +383 -0
  122. package/src/runtime/turn-failure.ts +108 -0
  123. package/src/runtime/types.ts +148 -0
  124. package/src/runtime/usage-limit-error.ts +44 -0
  125. package/src/runtime/use-agent-activity.ts +80 -0
  126. package/src/runtime/use-conversation-stream.ts +1427 -0
  127. package/src/runtime/use-conversation-thread.ts +556 -0
  128. package/src/runtime/use-start-conversation.ts +107 -0
  129. package/src/runtime/use-thread-history.ts +64 -0
  130. package/src/runtime/use-thread-search.ts +95 -0
  131. package/src/runtime/use-turn-edit.ts +99 -0
  132. package/src/tools.ts +2 -2
  133. package/src/types.ts +18 -47
  134. package/src/components/AliaChatMessageList.tsx +0 -532
  135. package/src/components/ThinkingBlock.tsx +0 -43
  136. package/src/hooks/useAliaChat.ts +0 -573
  137. package/src/lib/catalogue.ts +0 -236
  138. package/src/lib/chat-stream.ts +0 -655
  139. package/src/lib/chat-transport.ts +0 -98
  140. package/src/lib/delegation.ts +0 -34
  141. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,92 @@
1
+ import { toast } from '@oxy.so/bloom/toast';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ import { useLabels } from '../lib/labels';
4
+ import { useAliaRuntime } from '../runtime/config';
5
+ import { useDictationStore } from './useDictation';
6
+ import { useTTS } from './useTTS';
7
+
8
+ export type ReadAloudState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
9
+
10
+ /**
11
+ * The screen whose player is sounding, if any.
12
+ *
13
+ * `useTTS` keeps its player PER HOOK INSTANCE while the playback state is one shared store — and
14
+ * a host can keep every visited chat mounted, each with its own thread and so its own instance.
15
+ * Starting a reply in one chat must stop a reply still playing in another, and only the instance
16
+ * that owns a player can stop it. So the owner registers its `stop` here, and anyone may call it.
17
+ */
18
+ let owner: { stop: () => void } | null = null;
19
+
20
+ /** Silence whatever a thread is reading aloud, on any mounted screen. */
21
+ export function stopReadingAloud(): void {
22
+ const current = owner;
23
+ owner = null;
24
+ current?.stop();
25
+ }
26
+
27
+ /**
28
+ * Read one reply aloud, as a toggle.
29
+ *
30
+ * - A press starts it; a second press on the same reply stops it — not `useTTS`'s pause, which
31
+ * would leave a half-read reply parked with nothing on screen saying so.
32
+ * - `blocked` is a call or a dictation holding the audio: the button is disabled, and anything
33
+ * already being read stops when either starts. Two voices at once, or the microphone recording
34
+ * Alia's own voice into the draft, is the thing this prevents.
35
+ * - A failure is a toast, and the button goes back to idle.
36
+ *
37
+ * It speaks with the runtime's `apiUrl` and the host's `speech` preferences.
38
+ */
39
+ export function useReadAloud(blocked: boolean) {
40
+ const { t } = useLabels();
41
+ const { apiUrl, speech } = useAliaRuntime();
42
+ const { readAloud, stop, activeMessageId, playbackState } = useTTS({ apiUrl, ...speech });
43
+ const self = useRef<{ stop: () => void }>({ stop });
44
+ self.current.stop = stop;
45
+ const owns = owner === self.current && activeMessageId !== null;
46
+
47
+ // A call or a dictation began: whatever is being read stops, wherever.
48
+ const dictating = useDictationStore((s) => s.isRecording);
49
+ const busy = blocked || dictating;
50
+ useEffect(() => {
51
+ if (busy) stopReadingAloud();
52
+ }, [busy]);
53
+
54
+ useEffect(() => {
55
+ if (!owns || playbackState !== 'error') return;
56
+ toast.error(t('chat.readAloudFailed'));
57
+ stopReadingAloud();
58
+ }, [owns, playbackState, t]);
59
+
60
+ // Leaving takes the player with it (`useTTS` releases it); the shared state has to go too, or
61
+ // every other screen would show a reply still playing.
62
+ useEffect(() => {
63
+ const me = self.current;
64
+ return () => {
65
+ if (owner === me) stopReadingAloud();
66
+ };
67
+ }, []);
68
+
69
+ const active = useRef(activeMessageId);
70
+ active.current = activeMessageId;
71
+ const toggle = useCallback(
72
+ (messageId: string, text: string, conversationId?: string) => {
73
+ const wasThis = active.current === messageId;
74
+ stopReadingAloud();
75
+ if (wasThis) return;
76
+ owner = self.current;
77
+ // The route makes a clip once and answers every later press — here or after a reload — from
78
+ // storage. `conversationId` files it under the conversation, so deleting that deletes it;
79
+ // the caller passes it only for a message the server holds (`Message.unsaved`): for one it
80
+ // does not, the route answers 404 and nothing would be read.
81
+ void readAloud(messageId, text, conversationId);
82
+ },
83
+ [readAloud],
84
+ );
85
+
86
+ const stateOf = useCallback(
87
+ (messageId: string): ReadAloudState => (activeMessageId === messageId ? playbackState : 'idle'),
88
+ [activeMessageId, playbackState],
89
+ );
90
+
91
+ return { toggle, stateOf, blocked: busy };
92
+ }
@@ -0,0 +1,24 @@
1
+ import { toast } from '@oxy.so/bloom/toast';
2
+ import { useCallback } from 'react';
3
+ import { useLabels } from '../lib/labels';
4
+ import { type AliaConversationShare, useShareConversation } from '../runtime/sharing';
5
+
6
+ /**
7
+ * What the header's share button does: mint the conversation's read-only link, then hand it to
8
+ * the host, which builds its own URL and shows its own share sheet. `undefined` without a host
9
+ * `onShare`, so there is no button.
10
+ */
11
+ export function useShareHandler(
12
+ onShare: ((share: AliaConversationShare) => void) | undefined,
13
+ ): ((conversationId: string | undefined) => void) | undefined {
14
+ const { share } = useShareConversation();
15
+ const { t } = useLabels();
16
+ const handle = useCallback(
17
+ (conversationId: string | undefined) => {
18
+ if (conversationId === undefined || onShare === undefined) return;
19
+ void share(conversationId).then(onShare, () => toast.error(t('chat.shareFailed')));
20
+ },
21
+ [share, onShare, t],
22
+ );
23
+ return onShare === undefined ? undefined : handle;
24
+ }
@@ -91,7 +91,7 @@ export function useSoundEffects(enabled: boolean = false, sounds: SoundSources =
91
91
 
92
92
  /**
93
93
  * Hook that automatically triggers sound effects based on voice state changes.
94
- * Wire this into the conversation page alongside useVoiceMode.
94
+ * Wire this into the conversation page from the launcher's state (`useVoiceLauncher`).
95
95
  */
96
96
  export function useVoiceSoundEffects({
97
97
  isVoiceActive,
@@ -0,0 +1,59 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import { useEffect, useMemo } from 'react';
3
+ import { aliaQueryKeys } from '../runtime/query-keys';
4
+ import { type ThoughtScope, useThoughtStore } from '../runtime/thought-store';
5
+ import type { FailedTurn } from '../runtime/turn-failure';
6
+ import type { Message } from '../runtime/types';
7
+
8
+ export interface UseThoughtScopeOptions {
9
+ /** The conversation being streamed into, or `undefined` for a new chat. */
10
+ conversationId: string | undefined;
11
+ messages: Message[];
12
+ conversationLoading: boolean;
13
+ isLoading: boolean;
14
+ failedTurn: FailedTurn | null;
15
+ }
16
+
17
+ /**
18
+ * This conversation as the thought panel reads it: its messages, whether they are all here yet,
19
+ * and the turn in flight — kept in the thought store.
20
+ *
21
+ * Keyed on the conversation and its messages, NOT on the panel being open. Syncing only while
22
+ * the panel was open meant the first press on a tool row rendered against whatever the store held
23
+ * before — another conversation's messages, or none — and found nothing by id. The store accepts
24
+ * this only while the open selection belongs to the same conversation, so a new-chat screen
25
+ * mounted underneath cannot blank a panel opened here.
26
+ *
27
+ * A load that failed is read off the query cache at the moment the memo recomputes: the load's
28
+ * end is what flips `conversationLoading`, so the read is fresh exactly when it matters.
29
+ */
30
+ export function useThoughtScope({
31
+ conversationId,
32
+ messages,
33
+ conversationLoading,
34
+ isLoading,
35
+ failedTurn,
36
+ }: UseThoughtScopeOptions): ThoughtScope {
37
+ const queryClient = useQueryClient();
38
+ const sync = useThoughtStore((s) => s.sync);
39
+
40
+ const scope = useMemo<ThoughtScope>(() => {
41
+ const loadFailed =
42
+ conversationId !== undefined &&
43
+ queryClient.getQueryState(aliaQueryKeys.conversations.detail(conversationId))?.status ===
44
+ 'error';
45
+ return {
46
+ conversationId: conversationId ?? null,
47
+ messages,
48
+ status: conversationLoading ? 'loading' : loadFailed ? 'failed' : 'ready',
49
+ isLoading,
50
+ failedTurn,
51
+ };
52
+ }, [conversationId, messages, conversationLoading, isLoading, failedTurn, queryClient]);
53
+
54
+ useEffect(() => {
55
+ sync(scope);
56
+ }, [scope, sync]);
57
+
58
+ return scope;
59
+ }
@@ -0,0 +1,145 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { useLabels } from '../lib/labels';
3
+ import {
4
+ type Message,
5
+ type ThreadMessage,
6
+ arrivedIds,
7
+ daySeparators,
8
+ threadSeamIds,
9
+ turnTimings,
10
+ } from '../runtime/model';
11
+ import { useTimelineShape } from './useTimelineWindow';
12
+
13
+ /** The history of a conversation that has none, as ONE array: a fresh `[]` per render would break every memo. */
14
+ const NO_HISTORY: ThreadMessage[] = [];
15
+
16
+ /** For a row the timings map has never heard of: a shared frozen object keeps memoised rows still. */
17
+ const EMPTY_TIMING = Object.freeze({ startedAt: null, endedAt: null });
18
+
19
+ /** True for Alia's own replies (a delegated agent's are not). */
20
+ function isAliaReply(message: Message): boolean {
21
+ return message.role === 'assistant' && !message.agentInfo;
22
+ }
23
+
24
+ export interface UseThreadRowsOptions {
25
+ /** The conversation being streamed into. */
26
+ messages: readonly Message[];
27
+ /** Everything said in the thread before it, oldest first. Empty where nothing is behind it. */
28
+ history: readonly ThreadMessage[] | undefined;
29
+ /** The conversation being streamed into: where the seam between the history and the live rows falls. */
30
+ conversationId: string | undefined;
31
+ /** The conversation's messages are still on their way. */
32
+ conversationLoading: boolean;
33
+ }
34
+
35
+ /**
36
+ * What the thread draws, derived once per change of its STRUCTURE.
37
+ *
38
+ * The live list as ids, roles, stamps and speakers is what a streamed token never changes, so
39
+ * everything derived from the whole thread (day lines, seams, turn timings, the last answer of
40
+ * Alia's, the window's ids) is keyed on that rather than on the messages: a token re-derives
41
+ * nothing, it re-renders the row it was written into and that is all.
42
+ */
43
+ export function useThreadRows({
44
+ messages,
45
+ history: historyMessages,
46
+ conversationId,
47
+ conversationLoading,
48
+ }: UseThreadRowsOptions) {
49
+ const { t, locale } = useLabels();
50
+ const history = historyMessages ?? NO_HISTORY;
51
+
52
+ const live = useMemo(
53
+ () => messages.filter((m) => m.role === 'user' || m.role === 'assistant'),
54
+ [messages],
55
+ );
56
+
57
+ /**
58
+ * Everything on screen, in reading order: the history first, the conversation being streamed
59
+ * into after it. Every position below — which row is last, which is the last of Alia's — is an
60
+ * index into THIS, not into the live messages.
61
+ */
62
+ const rows = useMemo<Message[]>(
63
+ () => (history.length === 0 ? live : [...history, ...live]),
64
+ [history, live],
65
+ );
66
+
67
+ const liveShape = useTimelineShape(live);
68
+ const shape = useMemo<readonly Message[]>(
69
+ () => (history.length === 0 ? liveShape : [...history, ...liveShape]),
70
+ [history, liveShape],
71
+ );
72
+ const ids = useMemo(() => shape.map((m) => m.id), [shape]);
73
+
74
+ /**
75
+ * The live rows that arrived since the last change to the list, which are the only ones that
76
+ * animate in. A history row never does, and neither does a conversation that was loaded or
77
+ * switched to (`arrivedIds`).
78
+ */
79
+ const previousLive = useRef<{ ids: readonly string[]; loading: boolean } | null>(null);
80
+ const arrived = useMemo(
81
+ () =>
82
+ arrivedIds(
83
+ previousLive.current?.ids ?? null,
84
+ previousLive.current?.loading ?? false,
85
+ liveShape,
86
+ ),
87
+ [liveShape],
88
+ );
89
+ useEffect(() => {
90
+ previousLive.current = { ids: liveShape.map((m) => m.id), loading: conversationLoading };
91
+ }, [liveShape, conversationLoading]);
92
+
93
+ /**
94
+ * Every turn's start and end, computed once for the whole thread: asked per row it is O(n²), and
95
+ * this list re-renders roughly twenty times a second while an answer streams.
96
+ */
97
+ const timings = useMemo(() => turnTimings(shape), [shape]);
98
+
99
+ /** The day each message starts, when it starts a new one: Bloom's inline date line above it. */
100
+ const dayLabels = useMemo(() => {
101
+ const labels = new Map<string, string>();
102
+ for (const { messageId, label } of daySeparators(shape, new Date(), locale)) {
103
+ labels.set(
104
+ messageId,
105
+ label.kind === 'today'
106
+ ? t('chat.today')
107
+ : label.kind === 'yesterday'
108
+ ? t('chat.yesterday')
109
+ : label.text,
110
+ );
111
+ }
112
+ return labels;
113
+ }, [shape, locale, t]);
114
+
115
+ /**
116
+ * Where one conversation of a thread ends and the next begins: the model was given a fresh
117
+ * context there, and everything above is out of its sight (`threadSeamIds`).
118
+ */
119
+ const seamIds = useMemo(
120
+ () => threadSeamIds(history, liveShape, conversationId ?? ''),
121
+ [history, liveShape, conversationId],
122
+ );
123
+
124
+ const timingOf = useCallback(
125
+ (messageId: string) => timings.get(messageId) ?? EMPTY_TIMING,
126
+ [timings],
127
+ );
128
+
129
+ const lastReplyIndex = useMemo(
130
+ () => shape.reduce((acc, m, i) => (isAliaReply(m) ? i : acc), -1),
131
+ [shape],
132
+ );
133
+
134
+ return {
135
+ rows,
136
+ live,
137
+ history,
138
+ ids,
139
+ arrived,
140
+ dayLabels,
141
+ seamIds,
142
+ lastReplyIndex,
143
+ timingOf,
144
+ };
145
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import type { ThreadHistory, ThreadSearchHit } from '../runtime/thread/thread-history';
3
+ import { useThreadHistory } from '../runtime/use-thread-history';
4
+ import { useThreadWindow } from '../runtime/use-thread-search';
5
+
6
+ /**
7
+ * What the thread looks at: the present, or a moment in its past.
8
+ *
9
+ * An agent's thread has two states and they are not variations of one. At the present, the
10
+ * history runs into the conversation being streamed into. At a moment — a hit of the thread's
11
+ * search — the screen shows the stretch around one old message and there is nothing live beneath
12
+ * it. The way back is leaving, not scrolling: the endpoint pages BACKWARDS only, so walking
13
+ * forward from a jump would be a road that ends.
14
+ *
15
+ * `threadHandle` is the agent's handle; without it the chat is one conversation with nothing
16
+ * behind it and there is nothing to page into, search or jump to.
17
+ */
18
+ export function useThreadView(
19
+ threadHandle: string | undefined,
20
+ conversationId: string | undefined,
21
+ ) {
22
+ const present = useThreadHistory(threadHandle, conversationId);
23
+ const [searchOpen, setSearchOpen] = useState(false);
24
+ /** The message the reader jumped to, by cursor, or `null` for the present. */
25
+ const [jumpedTo, setJumpedTo] = useState<string | null>(null);
26
+ const past = useThreadWindow(threadHandle, jumpedTo);
27
+ const jumped = jumpedTo !== null;
28
+
29
+ // Ask for the page above, unless one is already coming. Bloom fires `onStartReached` once per
30
+ // approach and re-arms when the reader leaves the zone, so the guards left are about the DATA.
31
+ const source = jumped
32
+ ? { ...past, loading: past.isLoadingMore || past.isLoading }
33
+ : { ...present, loading: present.isLoadingMore };
34
+ const { messages, hasMore, loading, loadMore: fetchMore } = source;
35
+ const loadMore = useCallback(() => {
36
+ if (!loading) void fetchMore();
37
+ }, [loading, fetchMore]);
38
+
39
+ const history = useMemo<ThreadHistory | undefined>(
40
+ () =>
41
+ threadHandle === undefined
42
+ ? undefined
43
+ : { messages, loading, ...(hasMore ? { loadMore } : {}) },
44
+ [threadHandle, messages, loading, hasMore, loadMore],
45
+ );
46
+
47
+ /**
48
+ * Open the thread around a hit, and close the search over it. The cursor rather than the
49
+ * message id: an id addresses a message, and only a cursor addresses a POSITION, which is what
50
+ * a window is asked for.
51
+ */
52
+ const jump = useCallback((hit: ThreadSearchHit) => {
53
+ setJumpedTo(hit.cursor);
54
+ setSearchOpen(false);
55
+ }, []);
56
+
57
+ return {
58
+ history,
59
+ /** The cursor jumped to, or `null` at the present. */
60
+ focusCursor: jumpedTo,
61
+ jumped,
62
+ jump,
63
+ backToLatest: useCallback(() => setJumpedTo(null), []),
64
+ searchOpen,
65
+ openSearch: useCallback(() => setSearchOpen(true), []),
66
+ closeSearch: useCallback(() => setSearchOpen(false), []),
67
+ };
68
+ }
@@ -0,0 +1,256 @@
1
+ import {
2
+ type Message,
3
+ TAIL,
4
+ TOP,
5
+ type TimelineAnchor,
6
+ type TimelinePosition,
7
+ collapseToTail,
8
+ pinAnchor,
9
+ resolveStart,
10
+ revealAbove,
11
+ sameTimelineShape,
12
+ timelinePositions,
13
+ } from '../runtime/model';
14
+ import type { AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
15
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react';
16
+ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
17
+
18
+ /**
19
+ * The list, handed back as the SAME array for as long as only what its
20
+ * messages say has changed (`sameTimelineShape`).
21
+ *
22
+ * The array it returns may hold an older copy of the message being streamed
23
+ * into, so it is for deriving structure — ids, roles, stamps, speakers — and
24
+ * never for drawing text.
25
+ */
26
+ export function useTimelineShape<
27
+ T extends Pick<Message, 'id' | 'role' | 'createdAt' | 'agentInfo'>,
28
+ >(list: readonly T[]): readonly T[] {
29
+ const shape = useRef(list);
30
+ if (!sameTimelineShape(shape.current, list)) shape.current = list;
31
+ return shape.current;
32
+ }
33
+
34
+ interface Options {
35
+ /** Every row's id, in reading order. Keep its identity stable across tokens. */
36
+ ids: readonly string[];
37
+ /**
38
+ * What a saved position belongs to — the conversation — or `null` for a view
39
+ * that is not remembered (a cursor jump's window of the past).
40
+ */
41
+ scrollKey: string | null;
42
+ /** The conversation's messages have loaded; a restore waits for it. */
43
+ ready: boolean;
44
+ /** Index of a cursor jump's target row, or -1. It is always mounted. */
45
+ focusIndex: number;
46
+ /** A turn is streaming; the window is never folded under it. */
47
+ turnInFlight: boolean;
48
+ atBottomThreshold: number;
49
+ threadRef: RefObject<AiChatThreadHandle | null>;
50
+ /** Ask for the page of history above the first loaded row. */
51
+ onLoadHistory?: () => void;
52
+ onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
53
+ }
54
+
55
+ /**
56
+ * What is still to be done to the scroll once the rows it needs are laid out.
57
+ * A restore remembers the list it was set against: that list may still be the
58
+ * previous conversation's, and only a list that changed after it can say the
59
+ * saved row is really gone.
60
+ */
61
+ type PendingScroll =
62
+ | { kind: 'end' }
63
+ | { kind: 'offset'; position: TimelinePosition; setAgainst: readonly string[] };
64
+
65
+ /**
66
+ * A restore is waiting for its conversation's rows: the row it was saved
67
+ * against is not on screen, and the list has not changed since it was set. A
68
+ * list that did change without bringing that row back means the row is gone
69
+ * (edited away, regenerated), and the restore falls back to the newest turn.
70
+ */
71
+ function awaitingRows(pending: { current: PendingScroll | null }, ids: readonly string[]): boolean {
72
+ const todo = pending.current;
73
+ if (todo?.kind !== 'offset' || ids.includes(todo.position.probeId)) return false;
74
+ if (ids === todo.setAgainst) return true;
75
+ pending.current = { kind: 'end' };
76
+ return false;
77
+ }
78
+
79
+ /** The state before the first key: nothing mounted yet, so nothing to remember. */
80
+ const MOUNTING = Symbol('mounting');
81
+
82
+ const nextFrame = (run: () => void): void => {
83
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
84
+ else setTimeout(run, 0);
85
+ };
86
+
87
+ /**
88
+ * Which rows the thread mounts, and the scroll that goes with them: the window
89
+ * over a long history, and the position a conversation is returned to.
90
+ *
91
+ * ## The window
92
+ *
93
+ * The newest `INITIAL_ROWS` rows mount first. Near the top, Bloom's
94
+ * `onStartReached` reveals `PAGE_ROWS` more — or, with none left, asks for the
95
+ * thread's history — and `maintainStartPosition` holds the reader on the row
96
+ * they were reading while either lands above. The window folds back to its
97
+ * opening size only when the reader is at the bottom and nothing is streaming.
98
+ * See `runtime/thread/timeline.ts` for why it is this and not virtualization.
99
+ *
100
+ * ## The position
101
+ *
102
+ * Leaving a conversation — by navigating away or by switching the screen to
103
+ * another — saves the offset, whether the reader was at the bottom, and the
104
+ * window's anchor. Coming back restores the anchor first, so the same rows are
105
+ * mounted above the reader, and the offset once they have laid out. Coming
106
+ * back from a cursor jump does not restore: "back to latest" means the latest.
107
+ */
108
+ export function useTimelineWindow({
109
+ ids,
110
+ scrollKey,
111
+ ready,
112
+ focusIndex,
113
+ turnInFlight,
114
+ atBottomThreshold,
115
+ threadRef,
116
+ onLoadHistory,
117
+ onScroll,
118
+ }: Options) {
119
+ const [state, setState] = useState<{ key: string | null; anchor: TimelineAnchor }>({
120
+ key: scrollKey,
121
+ anchor: TAIL,
122
+ });
123
+ // The render after a key change and before its effect: the new conversation
124
+ // starts from the newest rows until the effect says otherwise.
125
+ const anchor = state.key === scrollKey ? state.anchor : TAIL;
126
+ const start = useMemo(() => resolveStart(anchor, ids, focusIndex), [anchor, ids, focusIndex]);
127
+
128
+ const setAnchor = useCallback(
129
+ (next: TimelineAnchor) => setState((current) => ({ key: current.key, anchor: next })),
130
+ [],
131
+ );
132
+
133
+ /** This render's facts, for callbacks and cleanups that outlive it. */
134
+ const latest = useRef({ ids, start, anchor, ready, turnInFlight, onLoadHistory });
135
+ useLayoutEffect(() => {
136
+ latest.current = { ids, start, anchor, ready, turnInFlight, onLoadHistory };
137
+ });
138
+
139
+ /** The last position the thread reported for the current key. */
140
+ const lastOffset = useRef<number | null>(null);
141
+ const atBottom = useRef(true);
142
+ const pending = useRef<PendingScroll | null>(null);
143
+ const previousKey = useRef<string | null | typeof MOUNTING>(MOUNTING);
144
+
145
+ useLayoutEffect(() => {
146
+ const cameFrom = previousKey.current;
147
+ previousKey.current = scrollKey;
148
+ lastOffset.current = null;
149
+ atBottom.current = true;
150
+ // Returning from a jump is a request for the present, not for the past.
151
+ // A reader who left from the bottom comes back to the newest page, which
152
+ // is where they were; only a position mid-thread brings its rows back.
153
+ const read =
154
+ scrollKey !== null && cameFrom !== null ? timelinePositions.read(scrollKey) : undefined;
155
+ const saved = read?.atBottom === false ? read : undefined;
156
+ pending.current =
157
+ scrollKey === null
158
+ ? null
159
+ : saved === undefined
160
+ ? { kind: 'end' }
161
+ : { kind: 'offset', position: saved, setAgainst: ids };
162
+ setState({ key: scrollKey, anchor: saved?.anchor ?? TAIL });
163
+
164
+ return () => {
165
+ if (scrollKey === null) return;
166
+ const { ids: rows, start: first, anchor: kept } = latest.current;
167
+ if (rows.length === 0) return;
168
+ timelinePositions.save(scrollKey, {
169
+ anchor: kept,
170
+ offset: lastOffset.current ?? 0,
171
+ atBottom: atBottom.current,
172
+ probeId: rows[first],
173
+ });
174
+ };
175
+ }, [scrollKey]);
176
+
177
+ /**
178
+ * Pin the opening window to its first row once the rows exist, so a turn
179
+ * appended at the bottom grows the window rather than sliding it — a slide
180
+ * would unmount the top row under a reader who is mid-thread. Re-pinned the
181
+ * same way when the pinned row has gone (the list was replaced) — except
182
+ * while a restore waits for its conversation's rows: the rows on screen are
183
+ * still the last conversation's, and the anchor being restored is not in
184
+ * them yet.
185
+ */
186
+ useLayoutEffect(() => {
187
+ if (ids.length === 0 || awaitingRows(pending, ids)) return;
188
+ const stale = anchor.kind === 'tail' || (anchor.kind === 'row' && !ids.includes(anchor.id));
189
+ if (!stale) return;
190
+ setState((current) =>
191
+ current.key === scrollKey && current.anchor === anchor
192
+ ? { key: current.key, anchor: pinAnchor(ids, start) }
193
+ : current,
194
+ );
195
+ }, [ids, anchor, start, scrollKey]);
196
+
197
+ const handleStartReached = useCallback(() => {
198
+ const { ids: rows, start: first, onLoadHistory: loadHistory } = latest.current;
199
+ const next = revealAbove(rows, first);
200
+ if (next !== null) {
201
+ setAnchor(next);
202
+ return;
203
+ }
204
+ setAnchor(TOP);
205
+ loadHistory?.();
206
+ }, [setAnchor]);
207
+
208
+ const handleScroll = useCallback(
209
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
210
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
211
+ lastOffset.current = contentOffset.y;
212
+ const wasAtBottom = atBottom.current;
213
+ atBottom.current =
214
+ contentSize.height - contentOffset.y - layoutMeasurement.height <= atBottomThreshold;
215
+ if (atBottom.current && !wasAtBottom && !latest.current.turnInFlight) {
216
+ const { ids: rows, start: first } = latest.current;
217
+ const folded = collapseToTail(rows, first);
218
+ if (folded !== null) {
219
+ pending.current = { kind: 'end' };
220
+ setAnchor(folded);
221
+ }
222
+ }
223
+ onScroll?.(event);
224
+ },
225
+ [atBottomThreshold, onScroll, setAnchor],
226
+ );
227
+
228
+ /**
229
+ * The rows have laid out: carry out whatever scroll was waiting for them.
230
+ * A frame later, so it lands after the thread's own reaction to the same
231
+ * layout (on web it follows to the end on its first one).
232
+ */
233
+ const handleListLayout = useCallback(
234
+ (_event: LayoutChangeEvent) => {
235
+ const { ids: rows, ready: loaded } = latest.current;
236
+ if (pending.current === null || !loaded || rows.length === 0) return;
237
+ if (awaitingRows(pending, rows)) return;
238
+ const todo = pending.current;
239
+ pending.current = null;
240
+ nextFrame(() => {
241
+ if (todo.kind === 'end') threadRef.current?.scrollToEnd({ animated: false });
242
+ else threadRef.current?.scrollToOffset({ offset: todo.position.offset });
243
+ });
244
+ },
245
+ [threadRef],
246
+ );
247
+
248
+ return {
249
+ /** Index of the first mounted row. */
250
+ start,
251
+ /** For `AiChatThread`: absent when there is nothing above to reveal or load. */
252
+ onStartReached: start > 0 || onLoadHistory !== undefined ? handleStartReached : undefined,
253
+ onScroll: handleScroll,
254
+ onListLayout: handleListLayout,
255
+ };
256
+ }