@alia.onl/sdk 15.7.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 (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -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/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { rememberOpener } from '../lib/focus-return';
3
+ import { useAliaRuntime } from '../runtime/config';
4
+ import { type ThoughtScope, type ThoughtTab, useThoughtStore } from '../runtime/thought-store';
5
+ import type { ThreadMessage } from '../runtime/thread/thread-history';
6
+
7
+ /** Open the thought panel on a turn; `opener` is the control pressed, to give focus back to. */
8
+ export type OpenThought = (messageId: string, tab?: ThoughtTab, opener?: unknown) => void;
9
+
10
+ /**
11
+ * The scope a press on a row hands the thought panel: the conversation the row belongs to, which
12
+ * is not always the one being streamed into.
13
+ *
14
+ * A live row belongs to the conversation on screen. A history row belongs to an earlier stretch
15
+ * of the thread — persisted and complete — so its scope is that stretch's messages and nothing
16
+ * about the live turn. Keyed by conversation and message: the panel never shows one
17
+ * conversation's turn under another's.
18
+ */
19
+ export function thoughtScopeFor(
20
+ messageId: string,
21
+ live: ThoughtScope,
22
+ history: readonly ThreadMessage[],
23
+ ): ThoughtScope {
24
+ if (live.messages.some((m) => m.id === messageId)) return live;
25
+ const past = history.find((m) => m.id === messageId);
26
+ if (past === undefined) return live;
27
+ return {
28
+ conversationId: past.conversationId,
29
+ messages: history.filter((m) => m.conversationId === past.conversationId),
30
+ status: 'ready',
31
+ isLoading: false,
32
+ failedTurn: null,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Open the thought panel on any turn — the one in flight or one long past — or `undefined` when
38
+ * the host has no panel to show it in (`AliaRuntimeHost.showThought`): the row then draws its
39
+ * "worked for" line as plain text.
40
+ *
41
+ * `runningTurnId` is the turn in flight once it has begun using tools. The first tool of a turn
42
+ * opens the panel on it unless the host declines (something else is open), once per turn: a
43
+ * reader who closes it keeps it closed.
44
+ *
45
+ * The returned function is stable: it is a prop of every memoised row, while the live scope
46
+ * changes per streamed token, so the scopes are read at press time through a ref.
47
+ */
48
+ export function useOpenThought(
49
+ live: ThoughtScope,
50
+ history: readonly ThreadMessage[],
51
+ runningTurnId: string | null,
52
+ ): OpenThought | undefined {
53
+ const { showThought } = useAliaRuntime().host;
54
+ const select = useThoughtStore((s) => s.open);
55
+ const latest = useRef({ live, history, showThought });
56
+ useEffect(() => {
57
+ latest.current = { live, history, showThought };
58
+ });
59
+
60
+ const open = useCallback<OpenThought>(
61
+ (messageId, tab, opener) => {
62
+ const { live: scope, history: past, showThought: show } = latest.current;
63
+ rememberOpener(opener);
64
+ select(messageId, thoughtScopeFor(messageId, scope, past), tab);
65
+ show?.('pressed');
66
+ },
67
+ [select],
68
+ );
69
+
70
+ const autoOpened = useRef<string | null>(null);
71
+ useEffect(() => {
72
+ if (runningTurnId === null || autoOpened.current === runningTurnId) return;
73
+ autoOpened.current = runningTurnId;
74
+ const { live: scope, showThought: show } = latest.current;
75
+ if (show?.('automatic') === true) select(runningTurnId, scope);
76
+ }, [runningTurnId, select]);
77
+
78
+ return showThought === undefined ? undefined : open;
79
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
2
  import { type AskedQuestion, pendingQuestions } from '../lib/questions';
3
- import type { ChatMessage } from '../types';
3
+ import type { Message } from '../runtime/types';
4
4
 
5
5
  /**
6
6
  * What Alia asked in her last turn and the person has not answered: asked above the composer,
@@ -8,7 +8,7 @@ import type { ChatMessage } from '../types';
8
8
  * puts the card away until she asks again.
9
9
  */
10
10
  export function usePendingQuestions(
11
- messages: readonly Pick<ChatMessage, 'id' | 'role' | 'toolInvocations'>[],
11
+ messages: readonly Pick<Message, 'id' | 'role' | 'toolInvocations'>[],
12
12
  isStreaming: boolean,
13
13
  ): { questions: AskedQuestion[]; dismiss: () => void } {
14
14
  const asked = useMemo(() => pendingQuestions(messages, isStreaming), [messages, isStreaming]);
@@ -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,
@@ -6,8 +6,7 @@ import { errorMessage } from '../lib/utils';
6
6
  import { create } from 'zustand';
7
7
  import { createAudioLevelMeter } from '../lib/audio-level';
8
8
  import { requestSpeechClip } from '../lib/speech-synthesis';
9
-
10
- const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
9
+ import { ALIA_API_URL } from '../lib/origins';
11
10
 
12
11
  // ============== OPTIONS ==============
13
12
 
@@ -83,7 +82,7 @@ useTTSStore.subscribe((state, previous) => {
83
82
  // ============== HOOK ==============
84
83
 
85
84
  export function useTTS(options: UseTTSOptions = {}) {
86
- const apiUrl = options.apiUrl || API_URL;
85
+ const apiUrl = options.apiUrl || ALIA_API_URL;
87
86
  const voicePref = options.voice ?? 'female';
88
87
  const voiceModel = options.model;
89
88
  const tonePref = options.tone ?? 'default';
@@ -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
+ }