@lotics/ui 44.9.1 → 44.10.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.
@@ -464,12 +464,21 @@ Reach for it whenever an agent run happens inside a dialog — which is nearly a
464
464
  const run = useAgentRun("intake"); // @lotics/app-sdk
465
465
  <AgentRunScope> {/* wraps the Dialog, like ClarifyWizardScope */}
466
466
  <Dialog …>
467
- <AgentRunPane run={run} onCancel={back} /> {/* content */}
467
+ <AgentRunPane run={run} onCancel={back} onLanding={landed} /> {/* content */}
468
468
  <DialogFooter><AgentRunActions run={run} onStop={back} /></DialogFooter> {/* actions */}
469
469
  </Dialog>
470
470
  </AgentRunScope>
471
471
  ```
472
472
 
473
+ **Pass `onLanding` if the run's RESULT drives anything.** The pane owns the `answerChoice` call, so
474
+ after a question it is the only thing holding how the answered leg ended — your own `run()` promise
475
+ already resolved, with `parked`, and never resolves again. Give `onLanding` the same handler you
476
+ give `run()`: it is typed from your run (no cast), and it re-fires if answering leads to a follow-up
477
+ ask. Omit it and an answered run completes with the host none the wiser — the transcript reads
478
+ "done", your review step never opens, and the paid read is discarded. A host that instead reads its
479
+ result off `status`/`output` does not need it, but is then re-deriving from a render what it was
480
+ handed directly.
481
+
473
482
  **The parked question REPLACES the feed — that is the contract, not a style.** The run is blocked
474
483
  on the answer, so the question is the only thing to act on; it gets the dialog's own scroller and
475
484
  its actions sit outside it. Stacked under the transcript in an unscrollable box — the arrangement
package/docs/catalog.md CHANGED
@@ -2144,7 +2144,10 @@ component rather than showing it at zero.
2144
2144
  works and, the moment it asks, renders the question IN PLACE OF the feed — in the dialog's own
2145
2145
  scroller, with the wizard's verbs pinned in the `DialogFooter` via `AgentRunActions`. Replacing
2146
2146
  rather than stacking is the CONTRACT: stacked under the transcript a multi-question ask clips
2147
- its own Submit and strands a live `awaiting_input` run. `run` is a structural shape
2147
+ its own Submit and strands a live `awaiting_input` run. `onLanding` hands back how the ANSWERED
2148
+ leg ended — the pane makes that call, so without it the continuation's outcome is lost and a host
2149
+ waiting on its own `run()` promise waits forever (that promise already resolved `parked`). A
2150
+ refused answer keeps the question up and shows why, rather than a dead Submit. `run` is a structural shape
2148
2151
  (`AgentRunLike`), so `useAgentRun()` satisfies it with no `@lotics/app-sdk` dependency and a
2149
2152
  mock satisfies it with no backend — `AgentRunQuestion` names the question shape so a template
2150
2153
  or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.9.1",
3
+ "version": "44.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -1,7 +1,8 @@
1
- import { useCallback, useMemo, type ReactNode } from "react";
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { AgentRun, type AgentRunProps } from "./agent_run";
4
4
  import { Button } from "./button";
5
+ import { Text } from "./text";
5
6
  import { useLoticsLocale } from "./locale";
6
7
  import type { AgentUIPart } from "./agent_transform";
7
8
  import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
@@ -31,12 +32,20 @@ export interface AgentRunQuestion {
31
32
  allow_custom?: boolean;
32
33
  }
33
34
 
34
- export interface AgentRunLike {
35
+ export interface AgentRunLike<TLanding = unknown> {
35
36
  status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
36
37
  parts: readonly AgentUIPart[];
37
38
  /** Non-null exactly while parked. */
38
39
  pendingChoice: { questions: AgentRunQuestion[] } | null;
39
- answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
40
+ /**
41
+ * GENERIC in what it resolves, and that is the whole point of the parameter.
42
+ * A run leg reports HOW it ended — settled with an output, parked again on a
43
+ * follow-up, failed, aborted — and this pane is the thing that calls it, so it
44
+ * is the only thing holding that answer. Naming the type would mean importing
45
+ * `@lotics/app-sdk` and breaking the one-way boundary above; inferring it from
46
+ * the caller's own run costs nothing and hands `onLanding` back fully typed.
47
+ */
48
+ answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<TLanding>;
40
49
  /**
41
50
  * STOP the run — server-side, not just locally. `useAgentRun().cancel` is
42
51
  * exactly this; do NOT pass its `abort`, which detaches the listener and
@@ -61,12 +70,30 @@ export function AgentRunScope({ children }: { children: ReactNode }) {
61
70
  return <ClarifyWizardScope>{children}</ClarifyWizardScope>;
62
71
  }
63
72
 
64
- export interface AgentRunPaneProps {
65
- run: AgentRunLike;
73
+ export interface AgentRunPaneProps<TLanding = unknown> {
74
+ run: AgentRunLike<TLanding>;
66
75
  labelForCall?: AgentRunProps["labelForCall"];
67
76
  renderToolOutput?: AgentRunProps["renderToolOutput"];
68
77
  /** Abandon a parked question — the run is left for the operator to retry. */
69
78
  onCancel: () => void;
79
+ /**
80
+ * How the ANSWERED leg ended. Required for correctness in any host that acts
81
+ * on a run's result, because this pane owns the `answerChoice` call and the
82
+ * caller's own `run()` promise already resolved — with `parked` — and will
83
+ * never resolve again.
84
+ *
85
+ * Without it the continuation's outcome had nowhere to go: the pane discarded
86
+ * it, and a host that had been told a landing is how you learn an outcome sat
87
+ * on a finished transcript with nothing to advance to. That shipped, and cost
88
+ * a paid extraction every time an agent asked a question.
89
+ *
90
+ * Re-fires for a follow-up ask: answering can land `parked` again.
91
+ *
92
+ * A host reading its result off hook state instead does not need this — but it
93
+ * is then re-deriving from a render what it was handed directly, which is the
94
+ * pattern the landing union exists to retire.
95
+ */
96
+ onLanding?: (landing: TLanding) => void;
70
97
  }
71
98
 
72
99
  /**
@@ -80,8 +107,9 @@ export interface AgentRunPaneProps {
80
107
  * dialog's height and clipped its own Submit. Every app rebuilt this arrangement
81
108
  * by hand and it only had to be got wrong once.
82
109
  */
83
- export function AgentRunPane(props: AgentRunPaneProps) {
84
- const { run, labelForCall, renderToolOutput, onCancel } = props;
110
+ export function AgentRunPane<TLanding>(props: AgentRunPaneProps<TLanding>) {
111
+ const { run, labelForCall, renderToolOutput, onCancel, onLanding } = props;
112
+ const loc = useLoticsLocale().agentRun;
85
113
  // The DIALOG's gutter, not this pane's own container measurement. Asking
86
114
  // `useContainerSize()` here answered "small" for a 760px dialog while the
87
115
  // header, scroll area and footer asked the SCREEN and answered "not small" —
@@ -101,17 +129,35 @@ export function AgentRunPane(props: AgentRunPaneProps) {
101
129
  })),
102
130
  [pending],
103
131
  );
132
+ // A REFUSED answer (400 invalid, 409 raced cancel) leaves the run parked and
133
+ // still answerable, so the wizard stays up and the reason goes above it. It
134
+ // used to be a bare `void`, which made that an unhandled rejection: the button
135
+ // did nothing, the question stayed, and nothing said why.
136
+ const [answerError, setAnswerError] = useState<string | null>(null);
104
137
  const submit = useCallback(
105
138
  (answers: ClarifyWizardAnswer[]) => {
106
- void run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom })));
139
+ setAnswerError(null);
140
+ run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom }))).then(
141
+ (landing) => onLanding?.(landing),
142
+ // A rejection is always an `Error` on the SDK's path, but a promise can
143
+ // carry anything, so the fallback is the localized label rather than
144
+ // whatever `String()` makes of a non-Error — "[object Object]" is not a
145
+ // thing to show an operator who is mid-answer.
146
+ (e: unknown) => setAnswerError(e instanceof Error ? e.message : loc.error),
147
+ );
107
148
  },
108
- [run],
149
+ [run, onLanding, loc],
109
150
  );
110
151
 
111
152
  if (pending) {
112
153
  return (
113
154
  <DialogScrollArea>
114
- <View style={{ paddingVertical: 4 }}>
155
+ <View style={{ paddingVertical: 4, gap: 8 }}>
156
+ {answerError !== null ? (
157
+ <Text size="sm" color="danger">
158
+ {answerError}
159
+ </Text>
160
+ ) : null}
115
161
  <ClarifyWizard questions={questions} onSubmit={submit} onCancel={onCancel} />
116
162
  </View>
117
163
  </DialogScrollArea>