@theoremjs/react 0.3.4-next.849.g2dfd688 → 0.3.4-next.853.gebc3c60

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
@@ -479,6 +479,8 @@ function MyChat() {
479
479
 
480
480
  To show the transcript and the composer, use `ChatTranscript` and `ChatComposerBar` from `/ui`. You can also draw your own from `chat.blocks`.
481
481
 
482
+ The composer bar has a selector for the model, its effort, and each option. When their values do not fit the bar, each selector shows only its icon, and a tooltip gives its name and value. When the icons do not fit either, the row scrolls inside the composer. A control you pass as `generationSelect` keeps its own look and scrolls with the row.
483
+
482
484
  `ChatTranscript` places `citation` blocks after the answer text by default (the Sources strip under the prose). Pass `bodyKindPlacement={{}}` when citations must stay in stream order between text segments (inline citations).
483
485
 
484
486
  The hooks and the client write no English text. They return kinds, codes, and states. You write the words.
@@ -1,4 +1,11 @@
1
1
  import type { TurnEvent } from '@theoremjs/agents';
2
+ /** The tool a live call is running: its name, and its activity label once the call runs. */
3
+ export type LiveActiveTool = {
4
+ name: string;
5
+ activity?: string;
6
+ };
7
+ /** The tool to show after `next`: only the running phase carries the activity, so a later phase of that tool keeps it. */
8
+ export declare function nextActiveTool(prev: LiveActiveTool | null, next: LiveActiveTool): LiveActiveTool;
2
9
  type LiveToolEventArgs = {
3
10
  /** The call whose gate the user is looking at, if one is open. */
4
11
  gateCallId: string | undefined;
@@ -7,7 +14,7 @@ type LiveToolEventArgs = {
7
14
  clearInterim: () => void;
8
15
  clearActiveTool: () => void;
9
16
  reportFailure: (err: unknown) => void;
10
- setActiveTool: (name: string) => void;
17
+ setActiveTool: (tool: LiveActiveTool) => void;
11
18
  };
12
19
  export type { LiveToolEventArgs };
13
20
  /** Apply a live turn event to active-tool / caption / error state. */
@@ -1,4 +1,8 @@
1
1
  import { TheoremStreamError } from '@theoremjs/web/client';
2
+ /** The tool to show after `next`: only the running phase carries the activity, so a later phase of that tool keeps it. */
3
+ export function nextActiveTool(prev, next) {
4
+ return next.activity === undefined && prev?.name === next.name ? prev : next;
5
+ }
2
6
  /**
3
7
  * A failed tool step as the host reported it: the user reads its wording (else
4
8
  * the labels' for its kind); the model's `message` stays builder detail.
@@ -25,7 +29,9 @@ function applyToolPhase(tool, args) {
25
29
  clearToolUnlessGated(args);
26
30
  return;
27
31
  }
28
- args.setActiveTool(tool.name);
32
+ args.setActiveTool(tool.phase === 'running' && tool.activity
33
+ ? { name: tool.name, activity: tool.activity }
34
+ : { name: tool.name });
29
35
  }
30
36
  /** Apply a live turn event to active-tool / caption / error state. */
31
37
  export function applyLiveTurnToolEvent(event, args) {
@@ -35,7 +35,7 @@ export declare function useLiveCall({ iface, connection, ...options }: LiveCallI
35
35
  isVideoOn: boolean;
36
36
  sessionActive: boolean;
37
37
  liveState: import("@theoremjs/web/client").LiveState;
38
- activeTool: string | null;
38
+ activeTool: import("../../client/live/apply-live-turn-tool-event.ts").LiveActiveTool | null;
39
39
  status: import("@theoremjs/web/client").LiveSessionStatus;
40
40
  textAvailable: boolean;
41
41
  textDraft: string;
@@ -77,7 +77,7 @@ export function useLiveCall({ iface, connection, ...options }) {
77
77
  const state = useMemo(() => liveState({
78
78
  status: ui.status,
79
79
  connectPhase: ui.connectPhase,
80
- toolName: ui.activeTool,
80
+ toolName: ui.activeTool?.name ?? null,
81
81
  isMuted: ui.isMuted,
82
82
  voiceEnabled: voiceAvailable,
83
83
  }), [ui.activeTool, ui.connectPhase, ui.isMuted, ui.status, voiceAvailable]);
@@ -108,7 +108,6 @@ export function useLiveCall({ iface, connection, ...options }) {
108
108
  setCaptions: ui.setCaptions,
109
109
  setInputLevel: ui.setInputLevel,
110
110
  setOutputLevel: ui.setOutputLevel,
111
- setActiveTool: ui.setActiveTool,
112
111
  setSessionPermissions: ui.setSessionPermissions,
113
112
  setVideoPreview: ui.setVideoPreview,
114
113
  setVideoFacingMode: ui.setVideoFacingMode,
@@ -2,7 +2,7 @@ import { type LabelOverrides, type SessionEventOf } from '@theoremjs/agents';
2
2
  import type { LiveConnectPhase, LiveGateAnswer, LiveSessionStatus, LiveToolGatePrompt, ToolGateResolution } from '@theoremjs/web/client';
3
3
  import { type ClientFailure } from '@theoremjs/web/client';
4
4
  import { type Dispatch, type SetStateAction } from 'react';
5
- import { applyLiveTurnToolEvent } from '../../client/live/apply-live-turn-tool-event.ts';
5
+ import { applyLiveTurnToolEvent, type LiveActiveTool } from '../../client/live/apply-live-turn-tool-event.ts';
6
6
  import { type LiveCaptionState } from '../../client/live/live-captions.ts';
7
7
  import type { LiveFacingMode, LiveVideoCapture } from '../../client/live/live-video.ts';
8
8
  /** UI + media state bag for the live runner. `labels` is the interface's: the profile's wording. */
@@ -21,8 +21,8 @@ export declare function useLiveRunnerUiState(labels: LabelOverrides): {
21
21
  setIsMuted: Dispatch<SetStateAction<boolean>>;
22
22
  isVideoOn: boolean;
23
23
  setIsVideoOn: Dispatch<SetStateAction<boolean>>;
24
- activeTool: string | null;
25
- setActiveTool: Dispatch<SetStateAction<string | null>>;
24
+ activeTool: LiveActiveTool | null;
25
+ setActiveTool: Dispatch<SetStateAction<LiveActiveTool | null>>;
26
26
  captions: LiveCaptionState;
27
27
  setCaptions: Dispatch<SetStateAction<LiveCaptionState>>;
28
28
  failure: ClientFailure | null;
@@ -53,7 +53,7 @@ export declare function useLiveRunnerUiState(labels: LabelOverrides): {
53
53
  /** Gate prompt + live turn-tool event wiring. */
54
54
  export declare function useLiveRunnerGate(args: {
55
55
  setCaptions: Dispatch<SetStateAction<LiveCaptionState>>;
56
- setActiveTool: Dispatch<SetStateAction<string | null>>;
56
+ setActiveTool: Dispatch<SetStateAction<LiveActiveTool | null>>;
57
57
  reportFailure: (err: unknown) => void;
58
58
  }): {
59
59
  gatePrompt: LiveToolGatePrompt | null;
@@ -1,7 +1,7 @@
1
1
  import { TheoremError } from '@theoremjs/agents';
2
2
  import { clientFailure } from '@theoremjs/web/client';
3
3
  import { useCallback, useEffect, useRef, useState, } from 'react';
4
- import { applyLiveTurnToolEvent } from "../../client/live/apply-live-turn-tool-event.js";
4
+ import { applyLiveTurnToolEvent, nextActiveTool, } from "../../client/live/apply-live-turn-tool-event.js";
5
5
  import { applyLiveTurnEvent, clearLiveCaptionInterim, emptyLiveCaptionState, } from "../../client/live/live-captions.js";
6
6
  /** UI + media state bag for the live runner. `labels` is the interface's: the profile's wording. */
7
7
  export function useLiveRunnerUiState(labels) {
@@ -136,7 +136,9 @@ export function useLiveRunnerGate(args) {
136
136
  args.setActiveTool(null);
137
137
  },
138
138
  reportFailure: args.reportFailure,
139
- setActiveTool: args.setActiveTool,
139
+ setActiveTool: (tool) => {
140
+ args.setActiveTool((prev) => nextActiveTool(prev, tool));
141
+ },
140
142
  });
141
143
  }, [args, answerGate]);
142
144
  const waitForGateDecision = useCallback((prompt) => {
@@ -24,7 +24,6 @@ export type LiveClientBindings = LiveCallOptions & {
24
24
  setCaptions: Dispatch<SetStateAction<LiveCaptionState>>;
25
25
  setInputLevel: Dispatch<SetStateAction<number>>;
26
26
  setOutputLevel: Dispatch<SetStateAction<number>>;
27
- setActiveTool: Dispatch<SetStateAction<string | null>>;
28
27
  setSessionPermissions: Dispatch<SetStateAction<string[]>>;
29
28
  setVideoPreview: Dispatch<SetStateAction<HTMLVideoElement | null>>;
30
29
  setVideoFacingMode: Dispatch<SetStateAction<LiveFacingMode>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/react",
3
- "version": "0.3.4-next.849.g2dfd688",
3
+ "version": "0.3.4-next.853.gebc3c60",
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.849.g2dfd688",
64
- "@theoremjs/web": "^0.3.4-next.849.g2dfd688"
63
+ "@theoremjs/agents": "^0.3.4-next.853.gebc3c60",
64
+ "@theoremjs/web": "^0.3.4-next.853.gebc3c60"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@astryxdesign/core": {
@@ -5,7 +5,6 @@ import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
5
5
  import { HStack } from '@astryxdesign/core/HStack';
6
6
  import { IconButton } from '@astryxdesign/core/IconButton';
7
7
  import { Kbd } from '@astryxdesign/core/Kbd';
8
- import { Selector } from '@astryxdesign/core/Selector';
9
8
  import { StackItem } from '@astryxdesign/core/Stack';
10
9
  import { Text } from '@astryxdesign/core/Text';
11
10
  import { Thumbnail } from '@astryxdesign/core/Thumbnail';
@@ -20,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
20
19
  import { stageComposerFiles } from "../client/composer-attachments.js";
21
20
  import { useComposerVoice } from "../components/use-composer-voice.js";
22
21
  import { useEditorSelection } from "../components/use-editor-selection.js";
22
+ import { ComposerControls, ComposerSelector } from "./ComposerControls.js";
23
23
  import { composerDrawerLabel } from "./labels.js";
24
24
  import { TheoremLabelsProvider, useLabels } from "./labels-provider.js";
25
25
  import { VoiceNote } from "./VoiceNote.js";
@@ -78,7 +78,7 @@ export function generationOptions(iface, selectedModel) {
78
78
  function ModelSelector(props) {
79
79
  if (props.models.length === 0)
80
80
  return null;
81
- return (_jsx(Selector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconInputAi, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.models.map((m) => ({
81
+ return (_jsx(ComposerSelector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconInputAi, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.models.map((m) => ({
82
82
  value: m.id,
83
83
  label: m.id,
84
84
  description: m.label !== m.id ? m.label : undefined,
@@ -87,7 +87,7 @@ function ModelSelector(props) {
87
87
  function EffortSelector(props) {
88
88
  if (props.efforts.length === 0)
89
89
  return null;
90
- return (_jsx(Selector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(EFFORT_ICON, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.efforts.map((e) => ({ value: e.alias, label: e.alias, description: e.level })), onChange: props.onChange }));
90
+ return (_jsx(ComposerSelector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(EFFORT_ICON, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.efforts.map((e) => ({ value: e.alias, label: e.alias, description: e.level })), onChange: props.onChange }));
91
91
  }
92
92
  function OptionSelectors(props) {
93
93
  if (!props.choices)
@@ -103,17 +103,25 @@ function OptionSelectors(props) {
103
103
  label: choice.value,
104
104
  description: choice.description,
105
105
  }));
106
- return (_jsx(Selector, { label: title, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconAdjustmentsHorizontal, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: values[name] ?? '', options: [{ type: 'section', title, options: choices }], onChange: (value) => onChange(name, value) }, name));
106
+ return (_jsx(ComposerSelector, { label: title, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconAdjustmentsHorizontal, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: values[name] ?? '', options: [{ type: 'section', title, options: choices }], onChange: (value) => onChange(name, value) }, name));
107
107
  });
108
108
  }
109
109
  function GenerationSelect(props) {
110
110
  const { models, efforts } = generationOptions(props.iface, props.selectedModel);
111
111
  const optionCount = Object.keys(props.optionChoices?.declared ?? {}).length;
112
+ // invariant: What the controls show; when it changes they unfold and measure again.
113
+ const fit = JSON.stringify([
114
+ props.control
115
+ ? null
116
+ : [models.length, efforts.length, props.selectedModel, props.selectedEffort],
117
+ props.optionChoices?.values,
118
+ optionCount,
119
+ ]);
112
120
  if (props.control)
113
- return (_jsxs(HStack, { gap: 1, children: [props.control, _jsx(OptionSelectors, { choices: props.optionChoices, isDisabled: props.isDisabled })] }));
121
+ return (_jsxs(ComposerControls, { fit: fit, children: [props.control, _jsx(OptionSelectors, { choices: props.optionChoices, isDisabled: props.isDisabled })] }));
114
122
  if (models.length + efforts.length + optionCount === 0)
115
123
  return null;
116
- return (_jsxs(HStack, { gap: 1, children: [_jsx(ModelSelector, { label: labelText('composer.model', {}, props.iface.labels), models: models, value: props.selectedModel, isDisabled: props.isDisabled, onChange: (modelId) => props.onChange?.({ modelId }) }), _jsx(EffortSelector, { label: labelText('composer.effort', {}, props.iface.labels), efforts: efforts, value: props.selectedEffort, isDisabled: props.isDisabled, onChange: (effort) => {
124
+ return (_jsxs(ComposerControls, { fit: fit, children: [_jsx(ModelSelector, { label: labelText('composer.model', {}, props.iface.labels), models: models, value: props.selectedModel, isDisabled: props.isDisabled, onChange: (modelId) => props.onChange?.({ modelId }) }), _jsx(EffortSelector, { label: labelText('composer.effort', {}, props.iface.labels), efforts: efforts, value: props.selectedEffort, isDisabled: props.isDisabled, onChange: (effort) => {
117
125
  if (props.selectedModel)
118
126
  props.onChange?.({ modelId: props.selectedModel, effort });
119
127
  } }), _jsx(OptionSelectors, { choices: props.optionChoices, isDisabled: props.isDisabled })] }));
@@ -0,0 +1,16 @@
1
+ import { type SelectorProps } from '@astryxdesign/core/Selector';
2
+ import { type ReactNode } from 'react';
3
+ /**
4
+ * The composer's footer controls. Their labels fold to icons, each with its name and value in a
5
+ * tooltip, when the labels do not fit the row; if the icons do not fit either, the row scrolls.
6
+ * `fit` names what the controls show: when it changes they unfold and measure again.
7
+ */
8
+ export declare function ComposerControls(props: {
9
+ fit: string;
10
+ children: ReactNode;
11
+ }): import("react").JSX.Element;
12
+ /**
13
+ * A footer selector. Folded, it shows only its icon, and a tooltip gives its name and value; the
14
+ * name stays its accessible label either way.
15
+ */
16
+ export declare function ComposerSelector(selector: SelectorProps): import("react").JSX.Element;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { HStack } from '@astryxdesign/core/HStack';
3
+ import { Selector } from '@astryxdesign/core/Selector';
4
+ import { StackItem } from '@astryxdesign/core/Stack';
5
+ import { Tooltip } from '@astryxdesign/core/Tooltip';
6
+ import { createContext, useContext, useLayoutEffect, useReducer, useRef, useState, } from 'react';
7
+ const CompactContext = createContext(false);
8
+ /** The footer's controls scroll inside their room when even their icons do not fit. */
9
+ const ROW_SCROLL = { minWidth: 0, overflowX: 'auto', scrollbarWidth: 'thin' };
10
+ /**
11
+ * The room the composer's footer row leaves its controls: the row's content box, less the send
12
+ * cluster beside them and the gap between.
13
+ * why: Astryx wraps `footerActions` in a box that cannot shrink below its content, so the
14
+ * controls take a measured `max-width`, which also caps what they ask of that box.
15
+ */
16
+ function footerRoom(controls) {
17
+ const row = controls.parentElement?.parentElement;
18
+ const send = row?.lastElementChild;
19
+ if (!row || !(send instanceof HTMLElement) || send === controls.parentElement)
20
+ return null;
21
+ const style = getComputedStyle(row);
22
+ const inner = row.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
23
+ return Math.max(0, inner - send.offsetWidth - (Number.parseFloat(style.columnGap) || 0));
24
+ }
25
+ /**
26
+ * The composer's footer controls. Their labels fold to icons, each with its name and value in a
27
+ * tooltip, when the labels do not fit the row; if the icons do not fit either, the row scrolls.
28
+ * `fit` names what the controls show: when it changes they unfold and measure again.
29
+ */
30
+ export function ComposerControls(props) {
31
+ const ref = useRef(null);
32
+ const [room, setRoom] = useState(null);
33
+ // invariant: The width the labels need, measured when they last overflowed; null while they fit.
34
+ const [needed, setNeeded] = useState(null);
35
+ const [, remeasure] = useReducer((n) => n + 1, 0);
36
+ const compact = needed !== null && needed.fit === props.fit && (room ?? 0) < needed.width;
37
+ useLayoutEffect(() => {
38
+ const el = ref.current;
39
+ const row = el?.parentElement?.parentElement;
40
+ if (!el || !row)
41
+ return;
42
+ const observer = new ResizeObserver(remeasure);
43
+ observer.observe(row);
44
+ return () => observer.disconnect();
45
+ }, []);
46
+ useLayoutEffect(() => {
47
+ const el = ref.current;
48
+ if (!el)
49
+ return;
50
+ const next = footerRoom(el);
51
+ if (next !== room) {
52
+ setRoom(next);
53
+ return;
54
+ }
55
+ if (!compact && el.scrollWidth > el.clientWidth) {
56
+ setNeeded({ fit: props.fit, width: el.scrollWidth });
57
+ }
58
+ });
59
+ return (_jsx(CompactContext.Provider, { value: compact, children: _jsx(HStack, { ref: ref, gap: 1, style: room === null ? ROW_SCROLL : { ...ROW_SCROLL, maxWidth: room }, children: props.children }) }));
60
+ }
61
+ const NO_VALUE = () => null;
62
+ /**
63
+ * A footer selector. Folded, it shows only its icon, and a tooltip gives its name and value; the
64
+ * name stays its accessible label either way.
65
+ */
66
+ export function ComposerSelector(selector) {
67
+ const compact = useContext(CompactContext);
68
+ const value = selector.value;
69
+ return (_jsx(StackItem, { size: "static", children: _jsx(Tooltip, { isEnabled: compact, content: value ? `${selector.label}: ${value}` : selector.label, hasHoverIndication: false, children: _jsx(Selector, { ...selector, renderValue: compact ? NO_VALUE : selector.renderValue }) }) }));
70
+ }
@@ -2,11 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Dialog } from '@astryxdesign/core/Dialog';
3
3
  import { useLabels } from "./labels-provider.js";
4
4
  import { ApprovalCard, AuthChallengeCard, GATE_CARD_WIDTH } from "./ToolGateCard.js";
5
- /** Room around the gate card for its shadow. */
6
- const GATE_DIALOG_PADDING = 4;
7
- const GATE_DIALOG_WIDTH = GATE_CARD_WIDTH + 2 * GATE_DIALOG_PADDING * 4;
8
- /** The dialog draws no surface of its own: the card is the one chat shows, alone. */
9
- const GATE_DIALOG_BARE = { background: 'transparent', boxShadow: 'none' };
5
+ /** The dialog takes the card's corner (the bubble's --radius-chat), so none of its surface shows past the card. */
6
+ const GATE_DIALOG_CORNER = {
7
+ borderRadius: 'var(--radius-chat, var(--radius-container))',
8
+ };
10
9
  /**
11
10
  * Tool approvals and credential prompts, as a dialog that must be answered. It shows the card
12
11
  * chat shows inline and nothing else, and closes on the answer. `LiveRunner` and
@@ -17,7 +16,10 @@ export function LiveToolGateDialog({ call }) {
17
16
  const prompt = call.gatePrompt;
18
17
  const onResolve = call.resolveGateDecision;
19
18
  const agent = t('@theorem.agent.handle', { handle: call.handle });
20
- return (_jsx(Dialog, { isOpen: prompt !== null, purpose: "required", width: GATE_DIALOG_WIDTH, padding: GATE_DIALOG_PADDING, style: GATE_DIALOG_BARE,
19
+ return (_jsx(Dialog, { isOpen: prompt !== null, purpose: "required",
20
+ // why: The card is the one chat shows, a tint made for a page. The dialog's own surface,
21
+ // the card's size and radius, is that page; over the bare backdrop the tint goes grey.
22
+ width: GATE_CARD_WIDTH, padding: 0, style: GATE_DIALOG_CORNER,
21
23
  // why: "required" disables dismissal; should one slip through, it denies.
22
24
  onOpenChange: (open) => {
23
25
  if (!open)
@@ -17,6 +17,8 @@ import { approvalHeading } from "./approval-heading.js";
17
17
  import { TheoremLabelsProvider, useLabels } from "./labels-provider.js";
18
18
  /** The widest a gate card gets, in pixels. */
19
19
  export const GATE_CARD_WIDTH = 400;
20
+ /** why: A focused title shows no ring; the focus is for screen readers, as on Astryx's DialogHeader. */
21
+ const TITLE_FOCUS = { outline: 'none' };
20
22
  function formatInput(value) {
21
23
  if (typeof value === 'string')
22
24
  return value;
@@ -26,7 +28,7 @@ function formatInput(value) {
26
28
  }
27
29
  /** The one frame of every gate card: what is asked, a tag beside it, and what answers it. */
28
30
  function GateCard(props) {
29
- return (_jsx(Card, { variant: "bubble", elevation: "med", maxWidth: GATE_CARD_WIDTH, children: _jsxs(VStack, { gap: 3, children: [_jsxs(HStack, { justify: "between", align: "start", gap: 3, children: [_jsx(Text, { type: "large", weight: "semibold", children: props.heading }), props.tag] }), props.children] }) }));
31
+ return (_jsx(Card, { variant: "bubble", elevation: "med", maxWidth: GATE_CARD_WIDTH, children: _jsxs(VStack, { gap: 3, children: [_jsxs(HStack, { justify: "between", align: "start", gap: 3, children: [_jsx(Text, { type: "large", weight: "semibold", tabIndex: -1, "data-autofocus": true, style: TITLE_FOCUS, children: props.heading }), props.tag] }), props.children] }) }));
30
32
  }
31
33
  const ACCESS_ICON = {
32
34
  'read-only': IconEye,
package/ui/labels.d.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * @module
9
9
  */
10
- import type { ComposerDrawerSummary, LiveState } from '@theoremjs/web/client';
10
+ import { type ComposerDrawerSummary, type LiveState } from '@theoremjs/web/client';
11
+ import type { LiveActiveTool } from '../client/live/apply-live-turn-tool-event.ts';
11
12
  import type { TurnUsage, WorkStatus } from '../client/transcript-groups.ts';
12
13
  export declare const THEOREM_UI_CATALOG: {
13
14
  readonly '@theorem.agent.handle': {
@@ -1469,8 +1470,11 @@ export declare function resultMeta(t: LabelText, usd: Intl.NumberFormat, result:
1469
1470
  export declare function liveDuration(t: LabelText, durationMs: number): string;
1470
1471
  /** "Working for 12s" while streaming (when the start is known), "Worked for 3.2s" after. */
1471
1472
  export declare function workStatusLabel(t: LabelText, status: WorkStatus | null): string;
1472
- /** A live call's status line; `calling_tool` names the tool. */
1473
- export declare function liveStateLabel(t: LabelText, state: LiveState, toolName: string | null): string;
1473
+ /**
1474
+ * A live call's status line. While a tool runs it is the tool's activity label, as chat shows
1475
+ * it, else `calling_tool` with the tool's name in words, as its gate names it.
1476
+ */
1477
+ export declare function liveStateLabel(t: LabelText, state: LiveState, tool: LiveActiveTool | null): string;
1474
1478
  /** A voice note's name from its audio format; the unnamed line when the format is unknown. */
1475
1479
  export declare function voiceNoteName(t: LabelText, format: string | undefined): string;
1476
1480
  /**
package/ui/labels.js CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module
9
9
  */
10
+ import { humanize } from '@theoremjs/web/client';
10
11
  import { IntlMessageFormat } from 'intl-messageformat';
11
12
  export const THEOREM_UI_CATALOG = {
12
13
  '@theorem.agent.handle': {
@@ -1411,10 +1412,15 @@ export function workStatusLabel(t, status) {
1411
1412
  ? t('@theorem.transcript.worked')
1412
1413
  : t('@theorem.transcript.worked_for', { duration: workDuration(t, status.elapsedMs) });
1413
1414
  }
1414
- /** A live call's status line; `calling_tool` names the tool. */
1415
- export function liveStateLabel(t, state, toolName) {
1416
- if (state === 'calling_tool')
1417
- return t('@theorem.live.state.calling_tool', { tool: toolName ?? '' });
1415
+ /**
1416
+ * A live call's status line. While a tool runs it is the tool's activity label, as chat shows
1417
+ * it, else `calling_tool` with the tool's name in words, as its gate names it.
1418
+ */
1419
+ export function liveStateLabel(t, state, tool) {
1420
+ if (state === 'calling_tool') {
1421
+ return (tool?.activity ??
1422
+ t('@theorem.live.state.calling_tool', { tool: humanize(tool?.name ?? '').toLowerCase() }));
1423
+ }
1418
1424
  return t(`@theorem.live.state.${state}`);
1419
1425
  }
1420
1426
  /** A voice note's name from its audio format; the unnamed line when the format is unknown. */