@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.
- package/docs/ai_patterns.md +10 -1
- package/docs/catalog.md +4 -1
- package/package.json +1 -1
- package/src/agent_run_pane.tsx +56 -10
package/docs/ai_patterns.md
CHANGED
|
@@ -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} />
|
|
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. `
|
|
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
package/src/agent_run_pane.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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>
|