@opengeni/react 0.36.0 → 0.37.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 (45) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-J2RVJ6JX.js → chunk-4IJCL7YO.js} +13 -2
  3. package/dist/chunk-4IJCL7YO.js.map +1 -0
  4. package/dist/{chunk-OKKMZDQF.js → chunk-FSPDND3P.js} +2 -2
  5. package/dist/{chunk-FT2TXNGZ.js → chunk-HJ4OQVGW.js} +174 -14
  6. package/dist/chunk-HJ4OQVGW.js.map +1 -0
  7. package/dist/{chunk-PVW56EVR.js → chunk-IP22SLO6.js} +447 -13
  8. package/dist/chunk-IP22SLO6.js.map +1 -0
  9. package/dist/{chunk-U255M5EZ.js → chunk-SJKT4TKW.js} +39 -6
  10. package/dist/chunk-SJKT4TKW.js.map +1 -0
  11. package/dist/{chunk-XT7JF2EH.js → chunk-UCZPNXV3.js} +96 -17
  12. package/dist/chunk-UCZPNXV3.js.map +1 -0
  13. package/dist/components/chat-composer.d.ts +3 -1
  14. package/dist/components/model-policy-picker.d.ts +78 -0
  15. package/dist/components/session-chrome.d.ts +1 -1
  16. package/dist/composer.d.ts +2 -0
  17. package/dist/composer.js +17 -3
  18. package/dist/hooks/use-composer.d.ts +9 -0
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.js +30 -9
  21. package/dist/index.js.map +1 -1
  22. package/dist/model-policy.d.ts +12 -4
  23. package/dist/model-policy.js +5 -1
  24. package/dist/session-ui.js +2 -2
  25. package/dist/session.js +3 -3
  26. package/dist/timeline/types.d.ts +5 -0
  27. package/package.json +2 -2
  28. package/src/components/chat-composer.tsx +8 -3
  29. package/src/components/composer.tsx +17 -1
  30. package/src/components/copy-button.tsx +13 -6
  31. package/src/components/model-picker.tsx +2 -2
  32. package/src/components/model-policy-picker.tsx +582 -0
  33. package/src/components/session-chrome.tsx +138 -14
  34. package/src/composer.ts +13 -0
  35. package/src/hooks/use-composer.ts +213 -16
  36. package/src/index.ts +15 -0
  37. package/src/model-policy.ts +69 -14
  38. package/src/timeline/projection.ts +21 -1
  39. package/src/timeline/types.ts +5 -0
  40. package/dist/chunk-FT2TXNGZ.js.map +0 -1
  41. package/dist/chunk-J2RVJ6JX.js.map +0 -1
  42. package/dist/chunk-PVW56EVR.js.map +0 -1
  43. package/dist/chunk-U255M5EZ.js.map +0 -1
  44. package/dist/chunk-XT7JF2EH.js.map +0 -1
  45. /package/dist/{chunk-OKKMZDQF.js.map → chunk-FSPDND3P.js.map} +0 -0
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import type { SessionGoal, SessionPendingInputPreview, SessionTurn } from "@opengeni/sdk";
34
34
  import {
35
+ AudioLinesIcon,
35
36
  ArrowDownIcon,
36
37
  ArrowUpIcon,
37
38
  BotIcon,
@@ -64,7 +65,7 @@ import { cn } from "../lib/cn";
64
65
  import { requestQueueDraftEdit } from "./queue-draft-policy";
65
66
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
66
67
 
67
- export type SessionChromeSignalId = "incoming" | "queue" | "goal" | "agents";
68
+ export type SessionChromeSignalId = "incoming" | "steering" | "queue" | "goal" | "agents";
68
69
 
69
70
  export type SessionChromeSignalTone = "neutral" | "accent" | "waiting" | "running";
70
71
 
@@ -105,6 +106,11 @@ type GoalPillState =
105
106
  | "invariant_broken"
106
107
  | "completed";
107
108
 
109
+ type QueuedTurnPresentation = {
110
+ kind: "prompt" | "realtime_voice" | "realtime_voice_handoff";
111
+ text: string;
112
+ };
113
+
108
114
  const GOAL_LABEL: Record<GoalPillState, string> = {
109
115
  pursuing: "Pursuing",
110
116
  scheduled: "Scheduled",
@@ -115,6 +121,28 @@ const GOAL_LABEL: Record<GoalPillState, string> = {
115
121
  completed: "Done",
116
122
  };
117
123
 
124
+ function queuedTurnPresentation(turn: SessionTurn): QueuedTurnPresentation {
125
+ const realtimeDelegation = objectValue(turn.metadata.realtimeDelegation);
126
+ const inputTranscript = realtimeDelegation?.inputTranscript;
127
+ if (typeof inputTranscript === "string" && inputTranscript.trim()) {
128
+ return { kind: "realtime_voice", text: inputTranscript.trim() };
129
+ }
130
+ if (objectValue(turn.metadata.realtimeTailFlush)) {
131
+ return { kind: "realtime_voice_handoff", text: "Remaining voice context" };
132
+ }
133
+ return { kind: "prompt", text: turn.prompt };
134
+ }
135
+
136
+ function isSteeringTurn(turn: SessionTurn): boolean {
137
+ return turn.metadata.delivery === "steer";
138
+ }
139
+
140
+ function objectValue(value: unknown): Record<string, unknown> | null {
141
+ return value !== null && typeof value === "object" && !Array.isArray(value)
142
+ ? (value as Record<string, unknown>)
143
+ : null;
144
+ }
145
+
118
146
  /** Select pill state from the goal's authoritative continuation projection. */
119
147
  export function sessionChromeGoalPillState(
120
148
  goalStatus: "active" | "paused" | "completed",
@@ -215,6 +243,34 @@ export function SessionChrome({
215
243
  const record = goal?.goal ?? null;
216
244
  const incoming = queue.pendingInputs;
217
245
  const turns = queue.queue;
246
+ const composerSteering = composer?.steering ?? null;
247
+ const { steering, queuedTurns } = useMemo(() => {
248
+ const pendingQueueSteer =
249
+ turns.find((turn) => queue.pendingByTurn[turn.id] === "steer") ?? null;
250
+ const durableQueueSteer =
251
+ !pendingQueueSteer && turns[0] && isSteeringTurn(turns[0]) ? turns[0] : null;
252
+ const currentSteering =
253
+ composerSteering?.phase === "submitting"
254
+ ? composerSteering
255
+ : pendingQueueSteer
256
+ ? {
257
+ phase: "submitting" as const,
258
+ text: queuedTurnPresentation(pendingQueueSteer).text,
259
+ turnId: pendingQueueSteer.id,
260
+ }
261
+ : durableQueueSteer
262
+ ? {
263
+ phase: "accepted" as const,
264
+ text: queuedTurnPresentation(durableQueueSteer).text,
265
+ turnId: durableQueueSteer.id,
266
+ }
267
+ : composerSteering;
268
+ const currentTurnId = currentSteering?.turnId ?? null;
269
+ return {
270
+ steering: currentSteering,
271
+ queuedTurns: currentTurnId ? turns.filter((turn) => turn.id !== currentTurnId) : turns,
272
+ };
273
+ }, [composerSteering, queue.pendingByTurn, turns]);
218
274
  const canMutateQueue = !readOnly && composer !== undefined;
219
275
 
220
276
  const liveGoal =
@@ -250,14 +306,44 @@ export function SessionChrome({
250
306
  icon: <InboxIcon className="size-3" />,
251
307
  });
252
308
  }
253
- if (turns.length > 0) {
254
- const detail = turns[0]?.prompt;
309
+ if (steering) {
310
+ rows.push({
311
+ id: "steering",
312
+ label: "Changing direction…",
313
+ detail: steering.text,
314
+ tone: "accent",
315
+ icon:
316
+ steering.phase === "submitting" ? (
317
+ <Loader2Icon className="size-3 animate-og-spin" />
318
+ ) : (
319
+ <ZapIcon className="size-3" />
320
+ ),
321
+ });
322
+ }
323
+ if (queuedTurns.length > 0) {
324
+ const presentations = queuedTurns.map(queuedTurnPresentation);
325
+ const first = presentations[0];
326
+ const allVoiceRequests = presentations.every(({ kind }) => kind === "realtime_voice");
327
+ const onlyVoiceHandoff =
328
+ presentations.length === 1 && first?.kind === "realtime_voice_handoff";
329
+ const voiceOnly = allVoiceRequests || onlyVoiceHandoff;
330
+ const detail = first?.text;
255
331
  rows.push({
256
332
  id: "queue",
257
- label: `${turns.length} queued prompt${turns.length === 1 ? "" : "s"}`,
333
+ label: allVoiceRequests
334
+ ? queuedTurns.length === 1
335
+ ? "Voice request queued"
336
+ : `${queuedTurns.length} voice requests queued`
337
+ : onlyVoiceHandoff
338
+ ? "Voice handoff queued"
339
+ : `${queuedTurns.length} queued prompt${queuedTurns.length === 1 ? "" : "s"}`,
258
340
  ...(detail ? { detail } : {}),
259
341
  tone: "neutral",
260
- icon: <ListOrderedIcon className="size-3" />,
342
+ icon: voiceOnly ? (
343
+ <AudioLinesIcon className="size-3" />
344
+ ) : (
345
+ <ListOrderedIcon className="size-3" />
346
+ ),
261
347
  });
262
348
  }
263
349
  if (record && goalState) {
@@ -292,7 +378,7 @@ export function SessionChrome({
292
378
  });
293
379
  }
294
380
  return rows;
295
- }, [agentsSignal, elapsed, goalState, incoming, record, turns]);
381
+ }, [agentsSignal, elapsed, goalState, incoming, queuedTurns, record, steering]);
296
382
 
297
383
  const [activeUncontrolled, setActiveUncontrolled] = useState<SessionChromeSignalId | null>(
298
384
  defaultActive,
@@ -320,10 +406,10 @@ export function SessionChrome({
320
406
 
321
407
  useEffect(() => {
322
408
  if (active !== "queue" || !replaceDraftFor) return;
323
- if (!turns.some((turn) => turn.id === replaceDraftFor)) {
409
+ if (!queuedTurns.some((turn) => turn.id === replaceDraftFor)) {
324
410
  setReplaceDraftFor(null);
325
411
  }
326
- }, [active, replaceDraftFor, turns]);
412
+ }, [active, queuedTurns, replaceDraftFor]);
327
413
 
328
414
  useEffect(() => {
329
415
  const rail = railRef.current;
@@ -363,7 +449,7 @@ export function SessionChrome({
363
449
  const node = panelBodyRef.current;
364
450
  if (!node) return;
365
451
  setPanelHeight(node.offsetHeight);
366
- }, [open, active, incoming, turns, record, goalState, agentsPanel, agentsSignal]);
452
+ }, [open, active, incoming, queuedTurns, record, goalState, agentsPanel, agentsSignal, steering]);
367
453
 
368
454
  useEffect(() => {
369
455
  if (!open) return;
@@ -385,9 +471,11 @@ export function SessionChrome({
385
471
  const panelBody =
386
472
  active === "incoming" ? (
387
473
  <IncomingPanel inputs={incoming} onDismiss={onDismissIncoming} />
474
+ ) : active === "steering" && steering ? (
475
+ <SteeringPanel phase={steering.phase} text={steering.text} />
388
476
  ) : active === "queue" ? (
389
477
  <QueuePanel
390
- turns={turns}
478
+ turns={queuedTurns}
391
479
  readOnly={!canMutateQueue}
392
480
  mutationFor={queue.mutationFor}
393
481
  replaceDraftFor={replaceDraftFor}
@@ -661,6 +749,33 @@ function IncomingPanel({
661
749
  );
662
750
  }
663
751
 
752
+ function SteeringPanel({ phase, text }: { phase: "submitting" | "accepted"; text: string }) {
753
+ return (
754
+ <div
755
+ className="flex items-start gap-2 rounded-og-sm px-1.5 py-1"
756
+ role="status"
757
+ aria-live="polite"
758
+ data-og-session-chrome-panel="steering"
759
+ >
760
+ <span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-og-accent-soft text-og-accent">
761
+ {phase === "submitting" ? (
762
+ <Loader2Icon className="size-3 animate-og-spin" aria-hidden="true" />
763
+ ) : (
764
+ <ZapIcon className="size-3" aria-hidden="true" />
765
+ )}
766
+ </span>
767
+ <div className="min-w-0">
768
+ <p className="truncate text-og-xs font-medium leading-4 text-og-fg">{text}</p>
769
+ <p className="mt-0.5 text-[10px] leading-4 text-og-fg-muted">
770
+ {phase === "submitting"
771
+ ? "Sending your latest direction…"
772
+ : "Direction accepted. The agent will continue from it."}
773
+ </p>
774
+ </div>
775
+ </div>
776
+ );
777
+ }
778
+
664
779
  function QueuePanel({
665
780
  turns,
666
781
  readOnly,
@@ -691,6 +806,8 @@ function QueuePanel({
691
806
  data-og-session-chrome-panel="queue"
692
807
  >
693
808
  {turns.map((turn, index) => {
809
+ const presentation = queuedTurnPresentation(turn);
810
+ const voice = presentation.kind !== "prompt";
694
811
  const pending = mutationFor(turn.id);
695
812
  const beforeUp = index > 0 ? (turns[index - 1]?.id ?? null) : null;
696
813
  const beforeDown = index < turns.length - 1 ? (turns[index + 2]?.id ?? null) : null;
@@ -703,11 +820,18 @@ function QueuePanel({
703
820
  className="group flex flex-col gap-1 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--og-session-chrome-row-hover)]"
704
821
  >
705
822
  <div className="flex items-start gap-1.5">
706
- <span className="mt-px shrink-0 font-og-mono text-[10px] leading-4 text-og-fg-subtle">
707
- {index + 1}
708
- </span>
823
+ {voice ? (
824
+ <AudioLinesIcon
825
+ aria-hidden="true"
826
+ className="mt-0.5 size-3 shrink-0 text-og-accent"
827
+ />
828
+ ) : (
829
+ <span className="mt-px shrink-0 font-og-mono text-[10px] leading-4 text-og-fg-subtle">
830
+ {index + 1}
831
+ </span>
832
+ )}
709
833
  <p className="min-w-0 flex-1 truncate text-og-xs leading-4 text-og-fg">
710
- {turn.prompt}
834
+ {presentation.text}
711
835
  </p>
712
836
  {showActions ? (
713
837
  <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 max-sm:opacity-100">
package/src/composer.ts CHANGED
@@ -4,6 +4,19 @@
4
4
  * `import * as Composer from "@opengeni/react/composer"`.
5
5
  */
6
6
  export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
7
+ export {
8
+ BillingClassMark,
9
+ ModelPolicyPicker,
10
+ ModelPolicyPickerMenu,
11
+ PickerAnimatedPage,
12
+ PickerBackHeader,
13
+ PickerNavRow,
14
+ defaultModelPolicyPickerMessages,
15
+ } from "./components/model-policy-picker";
16
+ export type {
17
+ ModelPolicyPickerMessages,
18
+ ModelPolicyPickerProps,
19
+ } from "./components/model-policy-picker";
7
20
  export {
8
21
  Actions,
9
22
  AttachButton,
@@ -225,6 +225,8 @@ export type ComposerState = {
225
225
  send: (text?: string) => Promise<boolean>;
226
226
  /** Supersede current direction with the draft. */
227
227
  steer: (text?: string) => Promise<boolean>;
228
+ /** Optimistic-to-durable projection for a Steer that has not started yet. */
229
+ steering?: ComposerSteeringState | null | undefined;
228
230
  sending: boolean;
229
231
  canSend: boolean;
230
232
  /** Pause the session without deleting its prompt queue. */
@@ -250,6 +252,59 @@ export type ComposerState = {
250
252
  clearError: () => void;
251
253
  };
252
254
 
255
+ export type ComposerSteeringState = {
256
+ phase: "submitting" | "accepted";
257
+ text: string;
258
+ clientEventId: string | null;
259
+ triggerEventId: string | null;
260
+ turnId: string | null;
261
+ };
262
+
263
+ const STEERING_SETTLEMENT_EVENT_TYPES = new Set([
264
+ "turn.started",
265
+ "turn.completed",
266
+ "turn.failed",
267
+ "turn.cancelled",
268
+ "turn.superseded",
269
+ ]);
270
+
271
+ function isSteeringSettlementEvent(event: SessionEvent): boolean {
272
+ return STEERING_SETTLEMENT_EVENT_TYPES.has(event.type);
273
+ }
274
+
275
+ function steeringAcceptedEvent(
276
+ steering: ComposerSteeringState,
277
+ events: readonly SessionEvent[],
278
+ ): SessionEvent | undefined {
279
+ return events.find(
280
+ (event) =>
281
+ event.type === "user.message" &&
282
+ steering.clientEventId !== null &&
283
+ event.clientEventId === steering.clientEventId,
284
+ );
285
+ }
286
+
287
+ function steeringSettledByEvents(
288
+ steering: ComposerSteeringState,
289
+ events: readonly SessionEvent[],
290
+ ): boolean {
291
+ const acceptedEventId =
292
+ steering.triggerEventId ?? steeringAcceptedEvent(steering, events)?.id ?? null;
293
+ return events.some((event) => {
294
+ if (steering.turnId && event.turnId === steering.turnId && isSteeringSettlementEvent(event)) {
295
+ return true;
296
+ }
297
+ if (event.type !== "turn.started" || !acceptedEventId) return false;
298
+ const payload = event.payload;
299
+ return (
300
+ typeof payload === "object" &&
301
+ payload !== null &&
302
+ "triggerEventId" in payload &&
303
+ payload.triggerEventId === acceptedEventId
304
+ );
305
+ });
306
+ }
307
+
253
308
  /**
254
309
  * Draft + send + Pause/Resume state for the chat composer — the only
255
310
  * human-to-agent input surface. The draft survives a failed send (nothing is
@@ -271,6 +326,17 @@ export function useComposer(
271
326
  // a parent may switch sessionId without remounting this public hook.
272
327
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
273
328
  const [sending, setSending] = useState(false);
329
+ const [steering, setSteering] = useState<ComposerSteeringState | null>(() =>
330
+ initialPendingOperation?.delivery === "steer"
331
+ ? {
332
+ phase: "submitting",
333
+ text: initialPendingOperation.input.text,
334
+ clientEventId: initialPendingOperation.input.clientEventId ?? null,
335
+ triggerEventId: null,
336
+ turnId: null,
337
+ }
338
+ : null,
339
+ );
274
340
  const [pausing, setPausing] = useState(false);
275
341
  const [resuming, setResuming] = useState(false);
276
342
  const [error, setError] = useState<Error | null>(null);
@@ -282,6 +348,8 @@ export function useComposer(
282
348
  () => initialShadow?.resources ?? [],
283
349
  );
284
350
  const pendingOperationRef = useRef<PendingComposerOperation | null>(initialPendingOperation);
351
+ const steeringSettlementEventsRef = useRef<SessionEvent[]>([]);
352
+ const steeringRef = useRef(steering);
285
353
  const pendingClientEventId = useRef<string | null>(
286
354
  initialPendingOperation?.input.clientEventId ?? null,
287
355
  );
@@ -303,6 +371,9 @@ export function useComposer(
303
371
  useLayoutEffect(() => {
304
372
  onDraftAppliedRef.current = onDraftApplied;
305
373
  }, [onDraftApplied]);
374
+ useLayoutEffect(() => {
375
+ steeringRef.current = steering;
376
+ }, [steering]);
306
377
  // Read through a ref so a new extras closure (created every render by
307
378
  // callers passing inline functions) does not invalidate `send`.
308
379
  const sendExtrasRef = useRef(options.sendExtras);
@@ -320,6 +391,7 @@ export function useComposer(
320
391
  targetGeneration.current += 1;
321
392
  draftReadGeneration.current += 1;
322
393
  pendingOperationRef.current = restorePendingComposerOperation(pendingOperationKey);
394
+ steeringSettlementEventsRef.current = [];
323
395
  pendingClientEventId.current = pendingOperationRef.current?.input.clientEventId ?? null;
324
396
  const shadow = pendingOperationRef.current?.newerShadow;
325
397
  localEditRevision.current = shadow ? 1 : 0;
@@ -333,6 +405,17 @@ export function useComposer(
333
405
  setStateTargetKey(targetKey);
334
406
  setValue(shadow?.text ?? "");
335
407
  setSending(false);
408
+ setSteering(
409
+ pendingOperationRef.current?.delivery === "steer"
410
+ ? {
411
+ phase: "submitting",
412
+ text: pendingOperationRef.current.input.text,
413
+ clientEventId: pendingOperationRef.current.input.clientEventId ?? null,
414
+ triggerEventId: null,
415
+ turnId: null,
416
+ }
417
+ : null,
418
+ );
336
419
  setPausing(false);
337
420
  setResuming(false);
338
421
  setError(null);
@@ -503,18 +586,76 @@ export function useComposer(
503
586
  if (!sessionId || !durableDrafts) return;
504
587
  return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
505
588
  }, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]);
589
+ const reconcileSteering = useCallback(async (): Promise<void> => {
590
+ if (!sessionId || !steeringRef.current) return;
591
+ const ownedTargetKey = targetKey;
592
+ let events: SessionEvent[];
593
+ try {
594
+ events = await client.listEvents(workspaceId, sessionId, {
595
+ includeTypes: [
596
+ "user.message",
597
+ "turn.started",
598
+ "turn.completed",
599
+ "turn.failed",
600
+ "turn.cancelled",
601
+ "turn.superseded",
602
+ ],
603
+ limit: 250,
604
+ payloadMode: "full",
605
+ });
606
+ } catch {
607
+ // Best effort: the live stream still settles steering when its event arrives.
608
+ return;
609
+ }
610
+ if (targetKeyRef.current !== ownedTargetKey) return;
611
+ setSteering((current) => {
612
+ if (!current) return current;
613
+ if (steeringSettledByEvents(current, events)) {
614
+ steeringSettlementEventsRef.current = [];
615
+ return null;
616
+ }
617
+ const accepted = steeringAcceptedEvent(current, events);
618
+ if (!accepted || current.triggerEventId) return current;
619
+ return {
620
+ ...current,
621
+ phase: "accepted",
622
+ triggerEventId: accepted.id,
623
+ };
624
+ });
625
+ }, [client, sessionId, targetKey, workspaceId]);
506
626
  useSessionEventTrigger(
507
627
  client,
508
628
  workspaceId,
509
629
  sessionId,
510
- isComposerDraftEvent,
511
- () => void loadDraft(false),
630
+ (event) => isComposerDraftEvent(event) || isSteeringSettlementEvent(event),
631
+ (event) => {
632
+ if (isComposerDraftEvent(event)) void loadDraft(false);
633
+ if (!isSteeringSettlementEvent(event)) return;
634
+ steeringSettlementEventsRef.current = [
635
+ ...steeringSettlementEventsRef.current.slice(-15),
636
+ event,
637
+ ];
638
+ setSteering((current) => {
639
+ if (!current || !steeringSettledByEvents(current, [event])) return current;
640
+ steeringSettlementEventsRef.current = [];
641
+ return null;
642
+ });
643
+ },
512
644
  {
513
- enabled: Boolean(sessionId) && durableDrafts,
645
+ enabled: Boolean(sessionId) && (durableDrafts || steering !== null),
514
646
  ...(options.events !== undefined ? { events: options.events } : {}),
515
647
  },
648
+ reconcileSteering,
516
649
  );
517
650
 
651
+ useEffect(() => {
652
+ if (!steering) return;
653
+ const observed = [...(options.events ?? []), ...steeringSettlementEventsRef.current];
654
+ if (!steeringSettledByEvents(steering, observed)) return;
655
+ steeringSettlementEventsRef.current = [];
656
+ setSteering(null);
657
+ }, [options.events, steering]);
658
+
518
659
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
519
660
  if (!durableDrafts || targetKeyRef.current !== targetKey) return null;
520
661
  const base = draftRef.current;
@@ -667,6 +808,8 @@ export function useComposer(
667
808
  forgetPendingComposerOperation(operationKey);
668
809
  };
669
810
 
811
+ let keepSteering = pending?.delivery === "steer";
812
+
670
813
  const settleAccepted = (operation: PendingComposerOperation): void => {
671
814
  clearPending();
672
815
  const draftWasUnchanged = valueRef.current === operation.draftAtSend;
@@ -699,30 +842,40 @@ export function useComposer(
699
842
  onSent?.(operation.input.text, operation.input);
700
843
  };
701
844
 
702
- const deliver = async (operation: PendingComposerOperation): Promise<void> => {
845
+ const deliver = async (operation: PendingComposerOperation) => {
703
846
  if (operation.delivery === "steer") {
704
- await client.steerMessage(workspaceId, sessionId, operation.input);
705
- } else {
706
- await client.sendMessage(workspaceId, sessionId, operation.input);
847
+ return await client.steerMessage(workspaceId, sessionId, operation.input);
707
848
  }
849
+ await client.sendMessage(workspaceId, sessionId, operation.input);
850
+ return null;
708
851
  };
709
852
 
853
+ if (delivery === "steer") {
854
+ setSteering({
855
+ phase: "submitting",
856
+ text: rawText,
857
+ clientEventId: pending?.input.clientEventId ?? pendingClientEventId.current,
858
+ triggerEventId: null,
859
+ turnId: null,
860
+ });
861
+ }
710
862
  setSending(true);
711
863
  setError(null);
712
864
  try {
713
865
  if (pending) {
714
- let accepted = false;
866
+ let acceptedEvent: SessionEvent | null = null;
715
867
  try {
716
868
  const events = await client.listEvents(workspaceId, sessionId, {
717
869
  includeTypes: ["user.message"],
718
870
  limit: 100,
719
871
  payloadMode: "none",
720
872
  });
721
- accepted = events.some(
722
- (event) =>
723
- event.type === "user.message" &&
724
- event.clientEventId === pending.input.clientEventId,
725
- );
873
+ acceptedEvent =
874
+ events.find(
875
+ (event) =>
876
+ event.type === "user.message" &&
877
+ event.clientEventId === pending.input.clientEventId,
878
+ ) ?? null;
726
879
  } catch (cause) {
727
880
  if (
728
881
  targetKeyRef.current === ownedTargetKey &&
@@ -738,7 +891,17 @@ export function useComposer(
738
891
  ) {
739
892
  return false;
740
893
  }
741
- if (accepted) {
894
+ if (acceptedEvent) {
895
+ if (pending.delivery === "steer") {
896
+ keepSteering = true;
897
+ setSteering({
898
+ phase: "accepted",
899
+ text: pending.input.text,
900
+ clientEventId: pending.input.clientEventId ?? null,
901
+ triggerEventId: acceptedEvent.id,
902
+ turnId: null,
903
+ });
904
+ }
742
905
  settleAccepted(pending);
743
906
  return true;
744
907
  }
@@ -751,8 +914,19 @@ export function useComposer(
751
914
  return false;
752
915
  }
753
916
  try {
754
- await deliver(pending);
917
+ const result = await deliver(pending);
918
+ if (pending.delivery === "steer" && result) {
919
+ keepSteering = true;
920
+ setSteering({
921
+ phase: "accepted",
922
+ text: pending.input.text,
923
+ clientEventId: pending.input.clientEventId ?? null,
924
+ triggerEventId: result.accepted.id,
925
+ turnId: result.turn.id,
926
+ });
927
+ }
755
928
  } catch (cause) {
929
+ if (pending.delivery === "steer") keepSteering = true;
756
930
  if (
757
931
  targetKeyRef.current === ownedTargetKey &&
758
932
  targetGeneration.current === ownedGeneration
@@ -812,11 +986,32 @@ export function useComposer(
812
986
  };
813
987
  pendingOperationRef.current = operation;
814
988
  rememberPendingComposerOperation(operationKey, operation);
989
+ if (delivery === "steer") {
990
+ setSteering({
991
+ phase: "submitting",
992
+ text: sendText,
993
+ clientEventId: input.clientEventId ?? null,
994
+ triggerEventId: null,
995
+ turnId: null,
996
+ });
997
+ }
815
998
  try {
816
- await deliver(operation);
999
+ const result = await deliver(operation);
1000
+ if (delivery === "steer" && result) {
1001
+ keepSteering = true;
1002
+ setSteering({
1003
+ phase: "accepted",
1004
+ text: sendText,
1005
+ clientEventId: input.clientEventId ?? null,
1006
+ triggerEventId: result.accepted.id,
1007
+ turnId: result.turn.id,
1008
+ });
1009
+ }
817
1010
  } catch (cause) {
818
1011
  if (!isOutcomeUnknownError(cause)) {
819
1012
  clearPending();
1013
+ } else if (delivery === "steer") {
1014
+ keepSteering = true;
820
1015
  }
821
1016
  if (
822
1017
  targetKeyRef.current === ownedTargetKey &&
@@ -840,6 +1035,7 @@ export function useComposer(
840
1035
  targetGeneration.current === ownedGeneration
841
1036
  ) {
842
1037
  setSending(false);
1038
+ if (delivery === "steer" && !keepSteering) setSteering(null);
843
1039
  }
844
1040
  }
845
1041
  },
@@ -1095,6 +1291,7 @@ export function useComposer(
1095
1291
  hasDraftContent,
1096
1292
  send,
1097
1293
  steer,
1294
+ steering: identityMatches ? steering : null,
1098
1295
  sending: identityMatches ? sending : false,
1099
1296
  canSend:
1100
1297
  identityMatches &&
package/src/index.ts CHANGED
@@ -393,6 +393,19 @@ export { defaultChatComposerMessages } from "./components/composer";
393
393
  export type { ChatComposerMessages } from "./components/composer";
394
394
  export { ModelPicker } from "./components/model-picker";
395
395
  export type { ModelPickerProps } from "./components/model-picker";
396
+ export {
397
+ BillingClassMark,
398
+ ModelPolicyPicker,
399
+ ModelPolicyPickerMenu,
400
+ PickerAnimatedPage,
401
+ PickerBackHeader,
402
+ PickerNavRow,
403
+ defaultModelPolicyPickerMessages,
404
+ } from "./components/model-policy-picker";
405
+ export type {
406
+ ModelPolicyPickerMessages,
407
+ ModelPolicyPickerProps,
408
+ } from "./components/model-policy-picker";
396
409
  export {
397
410
  advancedSourceSummary,
398
411
  availabilityReasonLabel,
@@ -404,7 +417,9 @@ export {
404
417
  findPickerRow,
405
418
  groupPickerRowsByBillingClass,
406
419
  labelLatencyMode,
420
+ labelReasoningEffort,
407
421
  payerSummaryForModel,
422
+ projectClientModelRows,
408
423
  projectPickerRows,
409
424
  runnableLatencyModesForModel,
410
425
  sortPickerRows,