@theoremjs/react 0.3.4-next.827.gdc3858d → 0.3.4-next.831.g934609e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,7 +147,7 @@ The profile, the keys, and the system prompt stay on the server. The browser rec
147
147
  | --- | --- |
148
148
  | `@theoremjs/react` | Hooks, transports, and client logic |
149
149
  | `@theoremjs/react/ui` | `TheoremChat`, `TheoremHost`, `TheoremDecision`, `TheoremEmbedding`, and their parts |
150
- | `@theoremjs/react/live` | `LiveRunner`, the voice and video call UI |
150
+ | `@theoremjs/react/live` | `useLiveCall` and `LiveRunner`, the voice and video call and its UI |
151
151
  | `@theoremjs/react/server` | `@theoremjs/web/server`: the handlers and the stores they use |
152
152
  | `@theoremjs/react/ui/theme` | `theoremTheme`, the base for your own Astryx theme |
153
153
  | `@theoremjs/react/ui/icons` | The icon set of the theme |
@@ -344,51 +344,59 @@ For your own UI, use `useTheoremEmbedding` for one request, and `embed(inputs, {
344
344
 
345
345
  A `live` profile runs a voice or video call. The call goes through a WebSocket relay that you host with `runSession` from `@theoremjs/agents`.
346
346
 
347
- `LiveRunner` needs two props:
347
+ `useLiveCall` holds the call, and `LiveRunner` draws it. `useLiveCall` needs two values:
348
348
 
349
349
  - `iface`: the interface of the profile. `interfaceFromProfile` (from `@theoremjs/agents/interface`) builds it.
350
- - `connection`: a function that tells the runner what to connect to.
350
+ - `connection`: a function that tells the call what to connect to.
351
351
 
352
352
  ```tsx
353
- import { LiveRunner } from '@theoremjs/react/live';
353
+ import { LiveRunner, useLiveCall } from '@theoremjs/react/live';
354
354
 
355
- <LiveRunner iface={iface} connection={() => ({ profile: 'support.voice' })} />
355
+ const call = useLiveCall({ iface, connection: () => ({ profile: 'support.voice' }) });
356
+
357
+ <LiveRunner call={call} />
356
358
  ```
357
359
 
358
360
  This code opens `wss://<your host>/api/live/relay?profile=<id>`. To send your own message to the relay, add `openMessage`. To use your own socket, add `createSocket`.
359
361
 
360
362
  `LiveRunner` also accepts `theme`, `mode`, `labels`, and `trace`. Tool gates in a call appear in a dialog over the call.
361
363
 
364
+ ### A call across pages
365
+
366
+ The call lasts as long as the component that calls `useLiveCall`. Call it above your routes, and the call goes on while the person moves to another page. `LiveRunner` can unmount and mount again on the same call. The call ends when the component that holds it unmounts.
367
+
368
+ Give `LiveRunner` an `onMinimize` to show a minimize control in the call. Theorem calls it and does not navigate: your code decides where to go, and the call goes on.
369
+
362
370
  ### The page's part in a call
363
371
 
364
- Three more props give the call values from the page. The profile declares each one; the page only supplies the value.
372
+ Three more values for `useLiveCall` come from the page. The profile declares each one; the page only supplies the value.
365
373
 
366
- | Prop | The profile declares | What the page gives |
374
+ | Value | The profile declares | What the page gives |
367
375
  | --- | --- | --- |
368
376
  | `options` | `inputs.options` | The value chosen for each option. It is read when the call starts. A value that changes during a call takes the call up again with it when the profile has resumption on; otherwise the next call has it. |
369
377
  | `context` | `inputs.context` with `from: ['client']` | Any JSON the agent should know, such as the page the person is on. |
370
378
  | `pageTools` | A function tool with `answeredBy: 'page'` | A function for each page tool, by name. It returns `{ output }`. |
371
379
 
372
- `stated` is a fourth prop, for a page you trust as yourself. It says the `path` and the `options` from `identity.options` that choose the call's prompt lines, and the server reads it only with `pageStatesTurn` set. A change during a call takes the call up again, as an option does. `TheoremChat` takes the same prop, on each turn.
380
+ `stated` is a fourth value, for a page you trust as yourself. It says the `path` and the `options` from `identity.options` that choose the call's prompt lines, and the server reads it only with `pageStatesTurn` set. A change during a call takes the call up again, as an option does. `TheoremChat` takes the same prop, on each turn.
373
381
 
374
382
  `model` and `effort` pick the call's model when the profile sets `allowModelSelect`. They are read when the call starts.
375
383
 
376
384
  ```tsx
377
- <LiveRunner
378
- iface={iface}
379
- connection={() => ({ profile: 'support.voice' })}
380
- options={{ language: 'fr' }}
381
- context={{ page: '/pricing', cart }}
382
- pageTools={{
385
+ const call = useLiveCall({
386
+ iface,
387
+ connection: () => ({ profile: 'support.voice' }),
388
+ options: { language: 'fr' },
389
+ context: { page: '/pricing', cart },
390
+ pageTools: {
383
391
  highlight: (args) => ({ output: { success: highlight(String(args.target)) } }),
384
- }}
385
- />
392
+ },
393
+ });
386
394
  ```
387
395
 
388
- - `context` goes with the start of the call. When its value changes, the runner sends it again. The agent reads it as background and does not reply to it.
396
+ - `context` goes with the start of the call. When its value changes, `useLiveCall` sends it again. The agent reads it as background and does not reply to it.
389
397
  - A page tool's output schema checks what the page returned. Every other tool runs on the relay, with its gates.
390
398
  - `TheoremChat` and `useTheoremChat` take the same `pageTools`. When a chat agent calls a page tool, the reply waits for the page's answer and then goes on. The person is shown no prompt.
391
- - The runner writes a console warning when the profile has a page tool with no function in `pageTools`, and when `pageTools` has a function for a tool that is not a page tool.
399
+ - `useLiveCall` writes a console warning when the profile has a page tool with no function in `pageTools`, and when `pageTools` has a function for a tool that is not a page tool.
392
400
 
393
401
  The live client checks a tool call with its relay before it invokes a page callback.
394
402
  It waits for permission and sign-in decisions first. A claimed dispatch grant supplies the final checked arguments.
@@ -417,7 +425,7 @@ A failed call does not reconnect. Without `live.sessionResumption`, a provider-e
417
425
 
418
426
  The relay is server code. To authenticate the conversation and resume a call in your relay, see [Your relay](https://github.com/masudl-hub/theoremai/blob/main/web/README.md#your-relay).
419
427
 
420
- To build your own call view, use `useLiveRunnerModel(iface, connection, { options, context, pageTools })` from `@theoremjs/react/ui`. It returns the state of the call, its captions, and its controls. `LiveCaptionsPanel` draws the captions.
428
+ To build your own call view, draw the call that `useLiveCall` returns. It holds the state of the call, its captions, and its controls. `LiveCaptionsPanel` from `@theoremjs/react/ui` draws the captions.
421
429
 
422
430
  ### A custom browser transport
423
431
 
@@ -1,13 +1,24 @@
1
1
  import type { LiveProfileInterface } from '@theoremjs/agents/interface';
2
2
  import type { LiveCallOptions, LiveConnection } from '@theoremjs/web/client';
3
3
  import { type LiveCaptionState, type LiveCaptionTurn } from '../../client/live/live-captions.ts';
4
+ /** What `useLiveCall` takes: the profile, what the call opens, and the host's part in the call. */
5
+ export type LiveCallInput = {
6
+ iface: LiveProfileInterface;
7
+ /**
8
+ * What the call opens: a host with a fixed profile returns its id; the studio
9
+ * returns its draft as the relay's open message.
10
+ */
11
+ connection: () => LiveConnection | Promise<LiveConnection>;
12
+ } & LiveCallOptions;
13
+ /** One live call: its state, captions, and controls. `LiveRunner` draws it. */
14
+ export type LiveCall = ReturnType<typeof useLiveCall>;
4
15
  /**
5
- * Owns LiveRunner state, session client, and stage callbacks.
6
- * `connection` resolves what the call opens: a host with a fixed profile returns
7
- * its id; the studio returns its draft as the relay's open message. `options`
8
- * are the host's part in the call: its options, its stated turn, the page's context, and its page tools.
16
+ * Owns a live call: its session client, state, and controls. The call lasts as
17
+ * long as the component that calls this hook, so a host that calls it above its
18
+ * routes keeps the call while the person moves between pages.
9
19
  */
10
- export declare function useLiveRunnerModel(iface: LiveProfileInterface, connection: () => LiveConnection | Promise<LiveConnection>, options?: LiveCallOptions): {
20
+ export declare function useLiveCall({ iface, connection, ...options }: LiveCallInput): {
21
+ iface: LiveProfileInterface;
11
22
  handle: string;
12
23
  traces: import("@theoremjs/web/client").TraceFeed;
13
24
  callStarted: boolean;
@@ -50,12 +50,11 @@ function useLiveCallLifecycle(controls, captionsRef, sessionActive, videoAvailab
50
50
  return { callStarted, ended, pastCalls, startCall, handleEnd, handleRestart };
51
51
  }
52
52
  /**
53
- * Owns LiveRunner state, session client, and stage callbacks.
54
- * `connection` resolves what the call opens: a host with a fixed profile returns
55
- * its id; the studio returns its draft as the relay's open message. `options`
56
- * are the host's part in the call: its options, its stated turn, the page's context, and its page tools.
53
+ * Owns a live call: its session client, state, and controls. The call lasts as
54
+ * long as the component that calls this hook, so a host that calls it above its
55
+ * routes keeps the call while the person moves between pages.
57
56
  */
58
- export function useLiveRunnerModel(iface, connection, options = {}) {
57
+ export function useLiveCall({ iface, connection, ...options }) {
59
58
  const ui = useLiveRunnerUiState(iface.labels);
60
59
  const gate = useLiveRunnerGate({
61
60
  setCaptions: ui.setCaptions,
@@ -74,7 +73,7 @@ export function useLiveRunnerModel(iface, connection, options = {}) {
74
73
  isMuted: ui.isMuted,
75
74
  voiceEnabled: voiceAvailable,
76
75
  }), [ui.activeTool, ui.connectPhase, ui.isMuted, ui.status, voiceAvailable]);
77
- // why: One feed per runner: every session it opens adds its records.
76
+ // why: One feed per call: every session it opens adds its records.
78
77
  const traces = useMemo(createTraceFeed, []);
79
78
  const { clientRef, ensureClient, clearClient } = useLiveSessionClient({
80
79
  options: options.options,
@@ -158,6 +157,7 @@ export function useLiveRunnerModel(iface, connection, options = {}) {
158
157
  }, [clientRef, statedJson]);
159
158
  usePageToolWarnings(iface.tools.page, options.pageTools);
160
159
  return {
160
+ iface,
161
161
  handle: iface.identity.handle,
162
162
  traces,
163
163
  callStarted,
package/live.d.ts CHANGED
@@ -4,5 +4,6 @@
4
4
  * @module
5
5
  */
6
6
  export type { LiveCallOptions, LiveConnection, PageTool, PageToolAnswer, PageTools, } from '@theoremjs/web/client';
7
+ export { type LiveCall, type LiveCallInput, useLiveCall, } from './components/live/use-live-call.ts';
7
8
  export type { LiveRunnerProps } from './ui/LiveRunner.tsx';
8
9
  export { LiveRunner } from './ui/LiveRunner.tsx';
package/live.js CHANGED
@@ -7,4 +7,5 @@
7
7
  import '@astryxdesign/core/reset.css';
8
8
  import '@astryxdesign/core/astryx.css';
9
9
  import './ui/built/theme.css';
10
+ export { useLiveCall, } from "./components/live/use-live-call.js";
10
11
  export { LiveRunner } from "./ui/LiveRunner.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/react",
3
- "version": "0.3.4-next.827.gdc3858d",
3
+ "version": "0.3.4-next.831.g934609e",
4
4
  "description": "React bindings for Theorem: headless hooks + transport, an Astryx chat UI, and a Web-standard server handler.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,8 +60,8 @@
60
60
  "@stylexjs/stylex": "0.19.0",
61
61
  "react": "^19.0.0",
62
62
  "react-dom": "^19.0.0",
63
- "@theoremjs/agents": "^0.3.4-next.827.gdc3858d",
64
- "@theoremjs/web": "^0.3.4-next.827.gdc3858d"
63
+ "@theoremjs/agents": "^0.3.4-next.831.g934609e",
64
+ "@theoremjs/web": "^0.3.4-next.831.g934609e"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@astryxdesign/core": {
@@ -1,11 +1,9 @@
1
1
  import type { DefinedTheme } from '@astryxdesign/core/theme';
2
- import type { LiveProfileInterface } from '@theoremjs/agents/interface';
3
- import type { LiveCallOptions, LiveConnection } from '@theoremjs/web/client';
2
+ import type { LiveCall } from '../components/live/use-live-call.ts';
4
3
  import { type TheoremLabels } from './labels.ts';
5
4
  export type LiveRunnerProps = {
6
- iface: LiveProfileInterface;
7
- /** What the call opens: a profile the host registered, or an open message for the relay. */
8
- connection: () => LiveConnection | Promise<LiveConnection>;
5
+ /** The call to draw, from `useLiveCall`. The call outlives the runner: it ends when its owner unmounts. */
6
+ call: LiveCall;
9
7
  /** Astryx theme, as on `TheoremChat`. Omit to inherit the host's `<Theme>` or use `theoremTheme`. */
10
8
  theme?: DefinedTheme;
11
9
  mode?: 'system' | 'light' | 'dark';
@@ -18,7 +16,13 @@ export type LiveRunnerProps = {
18
16
  * Needs a profile that records traces.
19
17
  */
20
18
  trace?: boolean;
21
- } & LiveCallOptions;
19
+ /**
20
+ * Called when the person asks to minimize the call. The host decides what
21
+ * that means, such as going to another page; the call goes on. Omit to hide
22
+ * the control.
23
+ */
24
+ onMinimize?: () => void;
25
+ };
22
26
  /**
23
27
  * Live (voice / video) runner on Astryx. Before a call: a landing like the
24
28
  * chat's, offering a voice or video call. In a call: the agent tag and state
package/ui/LiveRunner.js CHANGED
@@ -13,10 +13,9 @@ import { Text } from '@astryxdesign/core/Text';
13
13
  import { ToggleButton } from '@astryxdesign/core/ToggleButton';
14
14
  import { Toolbar } from '@astryxdesign/core/Toolbar';
15
15
  import { VStack } from '@astryxdesign/core/VStack';
16
- import { IconCameraRotate, IconMessage, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneOff, IconVideo, IconVideoOff, } from '@tabler/icons-react';
16
+ import { IconArrowsDiagonalMinimize2, IconCameraRotate, IconMessage, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneOff, IconVideo, IconVideoOff, } from '@tabler/icons-react';
17
17
  import { Fragment, useEffect, useLayoutEffect, useRef, } from 'react';
18
18
  import { InkWaveform } from "../components/InkWaveform.js";
19
- import { useLiveRunnerModel } from "../components/live/use-live-runner-model.js";
20
19
  import { LiveCaptionsPanel } from "./LiveCaptionsPanel.js";
21
20
  import { liveStateLabel } from "./labels.js";
22
21
  import { TheoremLabelsProvider, useLabels } from "./labels-provider.js";
@@ -38,12 +37,11 @@ const WAVE_HEIGHT = 320;
38
37
  export function LiveRunner({ theme, mode, labels, ...props }) {
39
38
  return (_jsx(TheoremThemeProvider, { theme: theme, mode: mode, children: _jsx(TheoremLabelsProvider, { labels: labels, children: _jsx(LiveRunnerBody, { ...props }) }) }));
40
39
  }
41
- function LiveRunnerBody({ iface, connection, trace, ...options }) {
40
+ function LiveRunnerBody({ call, trace, onMinimize, }) {
42
41
  const t = useLabels();
43
- const model = useLiveRunnerModel(iface, connection, options);
44
42
  // why: Both panels size against the whole live layout, so a third means the same for each.
45
43
  const layoutRef = useRef(null);
46
- const inspector = useTraceInspector(iface, model.traces, trace);
44
+ const inspector = useTraceInspector(call.iface, call.traces, trace);
47
45
  const captions = useSidePanel(layoutRef, true);
48
46
  const captionLabels = {
49
47
  name: t('@theorem.panel.captions.name'),
@@ -52,29 +50,27 @@ function LiveRunnerBody({ iface, connection, trace, ...options }) {
52
50
  resize: t('@theorem.panel.captions.resize'),
53
51
  };
54
52
  const captionsToggle = (_jsx(SidePanelToggle, { labels: captionLabels, icon: _jsx(Icon, { icon: IconMessage }), panelId: captions.id, open: captions.open, onToggle: captions.toggle }));
55
- if (!model.callStarted) {
56
- return (_jsx(WithTrace, { inspector: inspector, children: _jsx(Layout, { ref: layoutRef, height: "fill", header: _jsx(SidePanelHeader, { children: inspector.toggle }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(LiveLanding, { model: model }) }) }) }));
53
+ if (!call.callStarted) {
54
+ return (_jsx(WithTrace, { inspector: inspector, children: _jsx(Layout, { ref: layoutRef, height: "fill", header: _jsx(SidePanelHeader, { children: inspector.toggle }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(LiveLanding, { call: call }) }) }) }));
57
55
  }
58
- return (_jsxs(_Fragment, { children: [_jsx(WithTrace, { inspector: inspector, children: _jsx(Layout, { ref: layoutRef, height: "fill", header: _jsx(SidePanelHeader, { children: inspector.toggle }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(Layout, { height: "fill", end: _jsx(SidePanel, { id: captions.id, labels: captionLabels, resizable: captions.resizable, open: captions.open, children: _jsx(LiveCaptions, { model: model }) }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(VStack, { height: "100%", padding: PANEL_EDGE, children: _jsx(LiveStage, { model: model, captionsToggle: captionsToggle }) }) }) }) }) }) }), _jsx(LiveToolGateDialog, { prompt: model.gatePrompt, agent: t('@theorem.agent.handle', { handle: model.handle }), onResolve: model.resolveGateDecision })] }));
56
+ return (_jsxs(_Fragment, { children: [_jsx(WithTrace, { inspector: inspector, children: _jsx(Layout, { ref: layoutRef, height: "fill", header: _jsx(SidePanelHeader, { children: inspector.toggle }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(Layout, { height: "fill", end: _jsx(SidePanel, { id: captions.id, labels: captionLabels, resizable: captions.resizable, open: captions.open, children: _jsx(LiveCaptions, { call: call }) }), content: _jsx(LayoutContent, { padding: 0, children: _jsx(VStack, { height: "100%", padding: PANEL_EDGE, children: _jsx(LiveStage, { call: call, captionsToggle: captionsToggle, onMinimize: onMinimize }) }) }) }) }) }) }), _jsx(LiveToolGateDialog, { prompt: call.gatePrompt, agent: t('@theorem.agent.handle', { handle: call.handle }), onResolve: call.resolveGateDecision })] }));
59
57
  }
60
58
  /**
61
59
  * Before a call: the chat landing's greeting, with call buttons where the
62
60
  * composer would be. Video starts camera and mic together.
63
61
  */
64
- function LiveLanding({ model }) {
62
+ function LiveLanding({ call }) {
65
63
  const t = useLabels();
66
- return (_jsx(VStack, { minHeight: "100%", vAlign: "center", gap: 8, paddingInline: 3, children: _jsx(Center, { axis: "horizontal", width: "100%", children: _jsxs(VStack, { width: "100%", maxWidth: DEFAULT_CHAT_MAX_WIDTH, gap: 8, children: [_jsxs(VStack, { gap: 1, children: [_jsx(Text, { type: "large", as: "h2", children: t('@theorem.agent.handle', { handle: model.handle }) }), _jsx(Text, { type: "display-2", as: "h1", children: t('@theorem.live.greeting') })] }), _jsxs(HStack, { gap: 2, wrap: "wrap", children: [_jsx(Button, { label: t(model.voiceAvailable
67
- ? '@theorem.live.start_voice_call'
68
- : '@theorem.live.start_call'), variant: "primary", icon: _jsx(Icon, { icon: IconPhone }), onClick: () => model.startCall({ video: false }) }), model.videoAvailable ? (_jsx(Button, { label: t('@theorem.live.start_video_call'), variant: "secondary", icon: _jsx(Icon, { icon: IconVideo }), onClick: () => model.startCall({ video: true }) })) : null] })] }) }) }));
64
+ return (_jsx(VStack, { minHeight: "100%", vAlign: "center", gap: 8, paddingInline: 3, children: _jsx(Center, { axis: "horizontal", width: "100%", children: _jsxs(VStack, { width: "100%", maxWidth: DEFAULT_CHAT_MAX_WIDTH, gap: 8, children: [_jsxs(VStack, { gap: 1, children: [_jsx(Text, { type: "large", as: "h2", children: t('@theorem.agent.handle', { handle: call.handle }) }), _jsx(Text, { type: "display-2", as: "h1", children: t('@theorem.live.greeting') })] }), _jsxs(HStack, { gap: 2, wrap: "wrap", children: [_jsx(Button, { label: t(call.voiceAvailable ? '@theorem.live.start_voice_call' : '@theorem.live.start_call'), variant: "primary", icon: _jsx(Icon, { icon: IconPhone }), onClick: () => call.startCall({ video: false }) }), call.videoAvailable ? (_jsx(Button, { label: t('@theorem.live.start_video_call'), variant: "secondary", icon: _jsx(Icon, { icon: IconVideo }), onClick: () => call.startCall({ video: true }) })) : null] })] }) }) }));
69
65
  }
70
66
  /**
71
67
  * The agent tag and state over the resting waveform, then the call controls,
72
68
  * all in one column the waveform's width.
73
69
  */
74
- function LiveStage({ model, captionsToggle }) {
70
+ function LiveStage({ call, captionsToggle, onMinimize, }) {
75
71
  const t = useLabels();
76
72
  const ref = useEnterMotion();
77
- return (_jsx(VStack, { ref: ref, height: "100%", hAlign: "center", children: _jsxs(VStack, { width: "100%", maxWidth: WAVE_MAX_WIDTH, height: "100%", gap: 3, children: [_jsxs(VStack, { gap: 0.5, children: [_jsx(Text, { type: "label", color: "secondary", children: t('@theorem.agent.handle', { handle: model.handle }) }), _jsx(Text, { size: "sm", color: "secondary", children: liveStateLabel(t, model.liveState, model.activeTool) })] }), model.failure ? _jsx(Banner, { status: "error", role: "alert", title: model.failure.error }) : null, model.sessionEnded ? (_jsx(Banner, { status: "info", role: "status", title: model.sessionEnded })) : null, _jsx(StackItem, { size: "fill", children: _jsx(VStack, { height: "100%", vAlign: "end", children: _jsx(VStack, { width: "100%", height: WAVE_HEIGHT, style: { color: 'var(--color-icon-primary)' }, "aria-hidden": "true", children: _jsx(InkWaveform, { status: model.status, levelsRef: model.levelsRef, toolActive: model.toolActive, variant: "hero" }) }) }) }), _jsx(LiveControls, { model: model, captionsToggle: captionsToggle })] }) }));
73
+ return (_jsx(VStack, { ref: ref, height: "100%", hAlign: "center", children: _jsxs(VStack, { width: "100%", maxWidth: WAVE_MAX_WIDTH, height: "100%", gap: 3, children: [_jsxs(VStack, { gap: 0.5, children: [_jsx(Text, { type: "label", color: "secondary", children: t('@theorem.agent.handle', { handle: call.handle }) }), _jsx(Text, { size: "sm", color: "secondary", children: liveStateLabel(t, call.liveState, call.activeTool) })] }), call.failure ? _jsx(Banner, { status: "error", role: "alert", title: call.failure.error }) : null, call.sessionEnded ? (_jsx(Banner, { status: "info", role: "status", title: call.sessionEnded })) : null, _jsx(StackItem, { size: "fill", children: _jsx(VStack, { height: "100%", vAlign: "end", children: _jsx(VStack, { width: "100%", height: WAVE_HEIGHT, style: { color: 'var(--color-icon-primary)' }, "aria-hidden": "true", children: _jsx(InkWaveform, { status: call.status, levelsRef: call.levelsRef, toolActive: call.toolActive, variant: "hero" }) }) }) }), _jsx(LiveControls, { call: call, captionsToggle: captionsToggle, onMinimize: onMinimize })] }) }));
78
74
  }
79
75
  /**
80
76
  * Eases the call view in from the landing: Astryx has no enter-transition
@@ -99,34 +95,44 @@ function useEnterMotion() {
99
95
  * Mic, camera, captions, and call controls, centred under the stage. Icons show
100
96
  * what a click does, as End call does: a slash means "turn this off".
101
97
  */
102
- function LiveControls({ model, captionsToggle }) {
98
+ function LiveControls({ call, captionsToggle, onMinimize, }) {
103
99
  const t = useLabels();
104
- const inactive = !model.sessionActive;
100
+ const inactive = !call.sessionActive;
105
101
  const controls = [];
106
- if (model.voiceAvailable) {
107
- controls.push(_jsx(ToggleButton, { label: t('@theorem.live.microphone'), tooltip: t(model.isMuted ? '@theorem.live.unmute' : '@theorem.live.mute'), isIconOnly: true, isPressed: !model.isMuted, isDisabled: inactive, icon: _jsx(Icon, { icon: IconMicrophone }), pressedIcon: _jsx(Icon, { icon: IconMicrophoneOff }), onPressedChange: model.handleToggleMic }, "mic"));
102
+ if (call.voiceAvailable) {
103
+ controls.push(_jsx(ToggleButton, { label: t('@theorem.live.microphone'), tooltip: t(call.isMuted ? '@theorem.live.unmute' : '@theorem.live.mute'), isIconOnly: true, isPressed: !call.isMuted, isDisabled: inactive, icon: _jsx(Icon, { icon: IconMicrophone }), pressedIcon: _jsx(Icon, { icon: IconMicrophoneOff }), onPressedChange: call.handleToggleMic }, "mic"));
108
104
  }
109
- if (model.videoAvailable) {
110
- controls.push(_jsx(ToggleButton, { label: t('@theorem.live.camera'), tooltip: t(model.isVideoOn ? '@theorem.live.camera_off' : '@theorem.live.camera_on'), isIconOnly: true, isPressed: model.isVideoOn, isDisabled: inactive, icon: _jsx(Icon, { icon: IconVideo }), pressedIcon: _jsx(Icon, { icon: IconVideoOff }), onPressedChange: () => {
111
- void model.handleToggleVideo();
112
- } }, "video"));
113
- if (model.isVideoOn) {
114
- controls.push(_jsx(IconButton, { label: t('@theorem.live.flip_camera'), tooltip: t('@theorem.live.flip_camera'), variant: "ghost", isDisabled: inactive, icon: _jsx(Icon, { icon: IconCameraRotate }), onClick: () => {
115
- void model.handleFlipCamera();
116
- } }, "flip"));
117
- }
105
+ if (call.videoAvailable)
106
+ controls.push(_jsx(LiveVideoControls, { call: call }, "video"));
107
+ if (onMinimize) {
108
+ controls.push(_jsx(IconButton, { label: t('@theorem.live.minimize'), tooltip: t('@theorem.live.minimize'), variant: "ghost", icon: _jsx(Icon, { icon: IconArrowsDiagonalMinimize2 }), onClick: onMinimize }, "minimize"));
118
109
  }
119
- controls.push(_jsx(Fragment, { children: captionsToggle }, "captions"), model.canRestart ? (_jsx(IconButton, { label: t('@theorem.live.start_call'), tooltip: t('@theorem.live.start_call'), variant: "primary", icon: _jsx(Icon, { icon: IconPhone }), onClick: () => {
120
- void model.handleRestart();
121
- } }, "call")) : (_jsx(IconButton, { label: t('@theorem.live.end_call'), tooltip: t('@theorem.live.end_call'), variant: "destructive", isDisabled: inactive, icon: _jsx(Icon, { icon: IconPhoneOff }), onClick: model.handleEnd }, "call")));
110
+ controls.push(_jsx(Fragment, { children: captionsToggle }, "captions"), _jsx(LiveCallButton, { call: call }, "call"));
122
111
  return (_jsx(Toolbar, { label: t('@theorem.live.controls'), dividers: ['top'], centerContent: _jsx(HStack, { gap: 2, vAlign: "center", children: controls }) }));
123
112
  }
113
+ /** The camera toggle, and the flip control while the camera is on. */
114
+ function LiveVideoControls({ call }) {
115
+ const t = useLabels();
116
+ const inactive = !call.sessionActive;
117
+ return (_jsxs(_Fragment, { children: [_jsx(ToggleButton, { label: t('@theorem.live.camera'), tooltip: t(call.isVideoOn ? '@theorem.live.camera_off' : '@theorem.live.camera_on'), isIconOnly: true, isPressed: call.isVideoOn, isDisabled: inactive, icon: _jsx(Icon, { icon: IconVideo }), pressedIcon: _jsx(Icon, { icon: IconVideoOff }), onPressedChange: () => {
118
+ void call.handleToggleVideo();
119
+ } }), call.isVideoOn ? (_jsx(IconButton, { label: t('@theorem.live.flip_camera'), tooltip: t('@theorem.live.flip_camera'), variant: "ghost", isDisabled: inactive, icon: _jsx(Icon, { icon: IconCameraRotate }), onClick: () => {
120
+ void call.handleFlipCamera();
121
+ } })) : null] }));
122
+ }
123
+ /** End call while one runs; Start call once it can start again. */
124
+ function LiveCallButton({ call }) {
125
+ const t = useLabels();
126
+ return call.canRestart ? (_jsx(IconButton, { label: t('@theorem.live.start_call'), tooltip: t('@theorem.live.start_call'), variant: "primary", icon: _jsx(Icon, { icon: IconPhone }), onClick: () => {
127
+ void call.handleRestart();
128
+ } })) : (_jsx(IconButton, { label: t('@theorem.live.end_call'), tooltip: t('@theorem.live.end_call'), variant: "destructive", isDisabled: !call.sessionActive, icon: _jsx(Icon, { icon: IconPhoneOff }), onClick: call.handleEnd }));
129
+ }
124
130
  /**
125
131
  * Captions drawn by the chat's own transcript, with the text composer in its
126
132
  * dock when the profile takes text.
127
133
  */
128
- function LiveCaptions({ model }) {
129
- return (_jsx(LiveCaptionsPanel, { handle: model.handle, captions: model.captions, pastCalls: model.pastCalls, draftText: model.textDraft, onDraftTextChange: model.setTextDraft, onSubmit: model.handleSendText, isDisabled: !model.sessionActive, showComposer: model.textAvailable, leading: model.videoPreview ? (_jsx(LiveVideoPreview, { video: model.videoPreview, facingMode: model.videoFacingMode })) : null }));
134
+ function LiveCaptions({ call }) {
135
+ return (_jsx(LiveCaptionsPanel, { handle: call.handle, captions: call.captions, pastCalls: call.pastCalls, draftText: call.textDraft, onDraftTextChange: call.setTextDraft, onSubmit: call.handleSendText, isDisabled: !call.sessionActive, showComposer: call.textAvailable, leading: call.videoPreview ? (_jsx(LiveVideoPreview, { video: call.videoPreview, facingMode: call.videoFacingMode })) : null }));
130
136
  }
131
137
  /**
132
138
  * The camera preview, at the top of the captions panel. The capture element comes
package/ui/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type { LiveCallOptions, PageTool, PageToolAnswer, PageTools, } from '@the
12
12
  export type { InkWaveStatus } from '../client/ink-waveform.ts';
13
13
  export { useLiveCaptionLog } from '../client/live/use-live-caption-log.ts';
14
14
  export { InkWaveform, type InkWaveformProps, type InkWaveLevels, } from '../components/InkWaveform.tsx';
15
- export { useLiveRunnerModel } from '../components/live/use-live-runner-model.ts';
15
+ export { type LiveCall, type LiveCallInput, useLiveCall, } from '../components/live/use-live-call.ts';
16
16
  export { theoremTheme } from './built/theorem.js';
17
17
  export { ChatComposerBar, type ChatComposerBarProps, EFFORT_ICON, generationOptions, type LiveComposerBarProps, } from './ChatComposerBar.tsx';
18
18
  export { ChatTranscript, type ChatTranscriptProps } from './ChatTranscript.tsx';
package/ui/index.js CHANGED
@@ -15,7 +15,7 @@ import '@astryxdesign/core/astryx.css';
15
15
  import './built/theme.css';
16
16
  export { useLiveCaptionLog } from "../client/live/use-live-caption-log.js";
17
17
  export { InkWaveform, } from "../components/InkWaveform.js";
18
- export { useLiveRunnerModel } from "../components/live/use-live-runner-model.js";
18
+ export { useLiveCall, } from "../components/live/use-live-call.js";
19
19
  export { theoremTheme } from './built/theorem.js';
20
20
  export { ChatComposerBar, EFFORT_ICON, generationOptions, } from "./ChatComposerBar.js";
21
21
  export { ChatTranscript } from "./ChatTranscript.js";
package/ui/labels.d.ts CHANGED
@@ -1364,6 +1364,10 @@ export declare const THEOREM_UI_CATALOG: {
1364
1364
  readonly defaultMessage: "End call";
1365
1365
  readonly description: "Hangs up.";
1366
1366
  };
1367
+ readonly '@theorem.live.minimize': {
1368
+ readonly defaultMessage: "Minimize call";
1369
+ readonly description: "Asks the host to show the call small; the call goes on.";
1370
+ };
1367
1371
  readonly '@theorem.live.new_session': {
1368
1372
  readonly defaultMessage: "New session";
1369
1373
  readonly description: "Captions divider between calls.";
package/ui/labels.js CHANGED
@@ -1291,6 +1291,10 @@ export const THEOREM_UI_CATALOG = {
1291
1291
  description: 'Switches front and back camera.',
1292
1292
  },
1293
1293
  '@theorem.live.end_call': { defaultMessage: 'End call', description: 'Hangs up.' },
1294
+ '@theorem.live.minimize': {
1295
+ defaultMessage: 'Minimize call',
1296
+ description: 'Asks the host to show the call small; the call goes on.',
1297
+ },
1294
1298
  '@theorem.live.new_session': {
1295
1299
  defaultMessage: 'New session',
1296
1300
  description: 'Captions divider between calls.',