@dowel-ui/react 0.1.1 → 0.3.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/LICENSE +1 -1
- package/README.md +193 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
- package/dist/components/ai-action-ledger/index.d.ts +2 -0
- package/dist/components/ai-action-ledger/index.js +2 -0
- package/dist/components/ai-action-ledger/meta.js +17 -0
- package/dist/components/ai-action-ledger/meta.js.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
- package/dist/components/ai-agent-plan/index.d.ts +2 -0
- package/dist/components/ai-agent-plan/index.js +2 -0
- package/dist/components/ai-agent-plan/meta.js +17 -0
- package/dist/components/ai-agent-plan/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
- package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
- package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
- package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
- package/dist/components/ai-approval-request/index.d.ts +2 -0
- package/dist/components/ai-approval-request/index.js +2 -0
- package/dist/components/ai-approval-request/meta.js +17 -0
- package/dist/components/ai-approval-request/meta.js.map +1 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
- package/dist/components/ai-inline-completion/index.d.ts +2 -0
- package/dist/components/ai-inline-completion/index.js +2 -0
- package/dist/components/ai-inline-completion/meta.js +17 -0
- package/dist/components/ai-inline-completion/meta.js.map +1 -0
- package/dist/components/ai-response/ai-response.d.ts.map +1 -1
- package/dist/components/ai-response/ai-response.js +1 -0
- package/dist/components/ai-response/ai-response.js.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
- package/dist/components/ai-structured-output/ai-structured-output.js +137 -0
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
- package/dist/components/ai-structured-output/index.d.ts +2 -0
- package/dist/components/ai-structured-output/index.js +2 -0
- package/dist/components/ai-structured-output/meta.js +17 -0
- package/dist/components/ai-structured-output/meta.js.map +1 -0
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/file-upload/file-upload.d.ts +46 -0
- package/dist/components/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/file-upload/file-upload.js +204 -0
- package/dist/components/file-upload/file-upload.js.map +1 -0
- package/dist/components/file-upload/index.d.ts +3 -0
- package/dist/components/file-upload/index.js +3 -0
- package/dist/components/file-upload/meta.js +17 -0
- package/dist/components/file-upload/meta.js.map +1 -0
- package/dist/components/file-upload/upload-queue.d.ts +87 -0
- package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
- package/dist/components/file-upload/upload-queue.js +249 -0
- package/dist/components/file-upload/upload-queue.js.map +1 -0
- package/dist/components/meter/index.d.ts +2 -0
- package/dist/components/meter/index.js +2 -0
- package/dist/components/meter/meta.js +17 -0
- package/dist/components/meter/meta.js.map +1 -0
- package/dist/components/meter/meter.d.ts +69 -0
- package/dist/components/meter/meter.d.ts.map +1 -0
- package/dist/components/meter/meter.js +144 -0
- package/dist/components/meter/meter.js.map +1 -0
- package/dist/components/metric-delta/index.d.ts +2 -0
- package/dist/components/metric-delta/index.js +2 -0
- package/dist/components/metric-delta/meta.js +17 -0
- package/dist/components/metric-delta/meta.js.map +1 -0
- package/dist/components/metric-delta/metric-delta.d.ts +59 -0
- package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
- package/dist/components/metric-delta/metric-delta.js +82 -0
- package/dist/components/metric-delta/metric-delta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/progress/progress.js +1 -0
- package/dist/components/progress/progress.js.map +1 -1
- package/dist/components/record-diff/index.d.ts +2 -0
- package/dist/components/record-diff/index.js +2 -0
- package/dist/components/record-diff/meta.js +17 -0
- package/dist/components/record-diff/meta.js.map +1 -0
- package/dist/components/record-diff/record-diff.d.ts +56 -0
- package/dist/components/record-diff/record-diff.d.ts.map +1 -0
- package/dist/components/record-diff/record-diff.js +199 -0
- package/dist/components/record-diff/record-diff.js.map +1 -0
- package/dist/components/spinner/spinner.js +1 -0
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/meta.js +17 -0
- package/dist/components/tags-input/meta.js.map +1 -0
- package/dist/components/tags-input/tags-input.d.ts +47 -0
- package/dist/components/tags-input/tags-input.d.ts.map +1 -0
- package/dist/components/tags-input/tags-input.js +182 -0
- package/dist/components/tags-input/tags-input.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -1
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +65 -45
- package/dist/registry/components.js.map +1 -1
- package/package.json +36 -4
- package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
- package/src/components/ai-action-ledger/index.ts +14 -0
- package/src/components/ai-action-ledger/meta.ts +19 -0
- package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
- package/src/components/ai-agent-plan/index.ts +10 -0
- package/src/components/ai-agent-plan/meta.ts +19 -0
- package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
- package/src/components/ai-approval-request/index.ts +7 -0
- package/src/components/ai-approval-request/meta.ts +21 -0
- package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
- package/src/components/ai-inline-completion/index.ts +1 -0
- package/src/components/ai-inline-completion/meta.ts +20 -0
- package/src/components/ai-response/ai-response.tsx +1 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +268 -0
- package/src/components/ai-structured-output/index.ts +10 -0
- package/src/components/ai-structured-output/meta.ts +18 -0
- package/src/components/file-upload/file-upload.tsx +322 -0
- package/src/components/file-upload/index.ts +20 -0
- package/src/components/file-upload/meta.ts +21 -0
- package/src/components/file-upload/upload-queue.ts +337 -0
- package/src/components/meter/index.ts +8 -0
- package/src/components/meter/meta.ts +20 -0
- package/src/components/meter/meter.tsx +219 -0
- package/src/components/metric-delta/index.ts +6 -0
- package/src/components/metric-delta/meta.ts +18 -0
- package/src/components/metric-delta/metric-delta.tsx +180 -0
- package/src/components/progress/progress.tsx +3 -0
- package/src/components/record-diff/index.ts +10 -0
- package/src/components/record-diff/meta.ts +19 -0
- package/src/components/record-diff/record-diff.tsx +341 -0
- package/src/components/spinner/spinner.tsx +5 -0
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/meta.ts +23 -0
- package/src/components/tags-input/tags-input.tsx +300 -0
- package/src/index.ts +10 -0
- package/src/registry/components.ts +20 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useMemo, useState, type ComponentPropsWithRef, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { disabledStyles, focusRing } from "@/lib/styles";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The gate between an agent deciding to act and it acting.
|
|
10
|
+
*
|
|
11
|
+
* Two things separate this from the confirmations that already exist.
|
|
12
|
+
*
|
|
13
|
+
* The first is that the proposed arguments are editable. Every implementation
|
|
14
|
+
* surveyed returns a boolean over a read-only payload, which forces a false
|
|
15
|
+
* choice: approve a call whose recipient is wrong, or deny it and make the
|
|
16
|
+
* model try again. "Approve this, but fix the address first" is the answer
|
|
17
|
+
* people actually want, and it is only possible if the arguments are fields.
|
|
18
|
+
* Edited values come back in the decision, and are marked as changed so an
|
|
19
|
+
* audit trail can tell the model's proposal from the human's correction.
|
|
20
|
+
*
|
|
21
|
+
* The second is that it renders while the arguments are still arriving. The
|
|
22
|
+
* common implementation returns null until the tool input is complete, which
|
|
23
|
+
* means nothing is on screen at the moment approval becomes relevant and the
|
|
24
|
+
* request appears abruptly, fully formed. Here the request is visible as it
|
|
25
|
+
* forms, with the decision held until there is something whole to decide on.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type ApprovalScope = "once" | "always";
|
|
29
|
+
|
|
30
|
+
export type ApprovalDecision =
|
|
31
|
+
| {
|
|
32
|
+
approved: true;
|
|
33
|
+
scope: ApprovalScope;
|
|
34
|
+
arguments: Record<string, string>;
|
|
35
|
+
edited: string[];
|
|
36
|
+
}
|
|
37
|
+
| { approved: false; reason?: string };
|
|
38
|
+
|
|
39
|
+
export interface ApprovalField {
|
|
40
|
+
name: string;
|
|
41
|
+
label: string;
|
|
42
|
+
/** A textarea instead of a single line. For message bodies and prompts. */
|
|
43
|
+
multiline?: boolean;
|
|
44
|
+
/** Locked arguments are shown but not editable — an id, a resolved amount. */
|
|
45
|
+
readOnly?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ApprovalRequestProps extends Omit<
|
|
49
|
+
ComponentPropsWithRef<"section">,
|
|
50
|
+
"onSubmit" | "children"
|
|
51
|
+
> {
|
|
52
|
+
/** The tool the model wants to call, as it named it. */
|
|
53
|
+
tool: string;
|
|
54
|
+
/** What the call will do, in the reader's language rather than the API's. */
|
|
55
|
+
summary: string;
|
|
56
|
+
/** Proposed arguments. Missing keys are treated as still arriving. */
|
|
57
|
+
arguments: Record<string, string>;
|
|
58
|
+
/** Which arguments to show, in order, and which may be changed. */
|
|
59
|
+
fields: ApprovalField[];
|
|
60
|
+
onDecision: (decision: ApprovalDecision) => void;
|
|
61
|
+
/**
|
|
62
|
+
* True while the model is still producing the arguments. The request renders
|
|
63
|
+
* anyway; only the decision waits.
|
|
64
|
+
*/
|
|
65
|
+
streaming?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Said plainly above the controls when the call cannot be taken back. Not a
|
|
68
|
+
* severity colour — the sentence is the warning.
|
|
69
|
+
*/
|
|
70
|
+
irreversible?: string;
|
|
71
|
+
/** Set once decided, to render the outcome instead of the controls. */
|
|
72
|
+
decision?: ApprovalDecision;
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ApprovalRequest({
|
|
77
|
+
className,
|
|
78
|
+
tool,
|
|
79
|
+
summary,
|
|
80
|
+
arguments: proposed,
|
|
81
|
+
fields,
|
|
82
|
+
onDecision,
|
|
83
|
+
streaming = false,
|
|
84
|
+
irreversible,
|
|
85
|
+
decision,
|
|
86
|
+
children,
|
|
87
|
+
...props
|
|
88
|
+
}: ApprovalRequestProps) {
|
|
89
|
+
const headingId = useId();
|
|
90
|
+
const [edits, setEdits] = useState<Record<string, string>>({});
|
|
91
|
+
const [denying, setDenying] = useState(false);
|
|
92
|
+
const [reason, setReason] = useState("");
|
|
93
|
+
|
|
94
|
+
// The model's proposal with the human's corrections applied on top. Keeping
|
|
95
|
+
// them apart is what lets the component say which is which.
|
|
96
|
+
const values = useMemo(() => ({ ...proposed, ...edits }), [proposed, edits]);
|
|
97
|
+
|
|
98
|
+
const edited = useMemo(
|
|
99
|
+
() => Object.keys(edits).filter((name) => edits[name] !== proposed[name]),
|
|
100
|
+
[edits, proposed],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const missing = fields.filter((field) => proposed[field.name] === undefined);
|
|
104
|
+
const settled = !streaming && missing.length === 0;
|
|
105
|
+
|
|
106
|
+
if (decision) {
|
|
107
|
+
return (
|
|
108
|
+
<section
|
|
109
|
+
data-slot="approval-request"
|
|
110
|
+
data-state={decision.approved ? "approved" : "denied"}
|
|
111
|
+
aria-labelledby={headingId}
|
|
112
|
+
className={cn(
|
|
113
|
+
"rounded-lg border p-4 text-sm",
|
|
114
|
+
decision.approved ? "border-border bg-muted/40" : "border-border bg-muted/40",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
<h3 id={headingId} className="text-sm font-medium">
|
|
120
|
+
{summary}
|
|
121
|
+
</h3>
|
|
122
|
+
<p data-slot="approval-outcome" className="mt-1 text-xs text-muted-foreground">
|
|
123
|
+
{decision.approved
|
|
124
|
+
? `Approved ${decision.scope === "always" ? "for every call to this tool" : "once"}` +
|
|
125
|
+
(decision.edited.length > 0
|
|
126
|
+
? `, with ${String(decision.edited.length)} argument${decision.edited.length === 1 ? "" : "s"} corrected`
|
|
127
|
+
: "")
|
|
128
|
+
: `Denied${decision.reason ? `: ${decision.reason}` : ""}`}
|
|
129
|
+
</p>
|
|
130
|
+
</section>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<section
|
|
136
|
+
data-slot="approval-request"
|
|
137
|
+
data-state={settled ? "pending" : "forming"}
|
|
138
|
+
aria-labelledby={headingId}
|
|
139
|
+
// Busy while the arguments are still arriving, so a reader is told the
|
|
140
|
+
// request is not yet whole rather than acting on half of it.
|
|
141
|
+
aria-busy={!settled}
|
|
142
|
+
className={cn("rounded-lg border border-border bg-card p-4", className)}
|
|
143
|
+
{...props}
|
|
144
|
+
>
|
|
145
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
146
|
+
<h3 id={headingId} className="text-sm font-medium">
|
|
147
|
+
{summary}
|
|
148
|
+
</h3>
|
|
149
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-2xs text-muted-foreground">
|
|
150
|
+
{tool}
|
|
151
|
+
</code>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<dl className="mt-3 flex flex-col gap-2.5">
|
|
155
|
+
{fields.map((field) => {
|
|
156
|
+
const arrived = proposed[field.name] !== undefined;
|
|
157
|
+
const changed = edited.includes(field.name);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<ApprovalArgument
|
|
161
|
+
key={field.name}
|
|
162
|
+
field={field}
|
|
163
|
+
value={values[field.name] ?? ""}
|
|
164
|
+
arrived={arrived}
|
|
165
|
+
changed={changed}
|
|
166
|
+
onChange={(next) => {
|
|
167
|
+
setEdits((current) => ({ ...current, [field.name]: next }));
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</dl>
|
|
173
|
+
|
|
174
|
+
{children}
|
|
175
|
+
|
|
176
|
+
{irreversible ? (
|
|
177
|
+
<p data-slot="approval-warning" className="mt-3 text-xs text-destructive">
|
|
178
|
+
{irreversible}
|
|
179
|
+
</p>
|
|
180
|
+
) : null}
|
|
181
|
+
|
|
182
|
+
{denying ? (
|
|
183
|
+
<div className="mt-3 flex flex-col gap-2">
|
|
184
|
+
<label htmlFor={`${headingId}-reason`} className="text-xs text-muted-foreground">
|
|
185
|
+
Why not? The model sees this.
|
|
186
|
+
</label>
|
|
187
|
+
<textarea
|
|
188
|
+
id={`${headingId}-reason`}
|
|
189
|
+
rows={2}
|
|
190
|
+
value={reason}
|
|
191
|
+
onChange={(event) => {
|
|
192
|
+
setReason(event.target.value);
|
|
193
|
+
}}
|
|
194
|
+
className={cn(
|
|
195
|
+
"w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-sm",
|
|
196
|
+
focusRing,
|
|
197
|
+
)}
|
|
198
|
+
/>
|
|
199
|
+
<div className="flex flex-wrap gap-2">
|
|
200
|
+
<Action
|
|
201
|
+
variant="destructive"
|
|
202
|
+
onClick={() => {
|
|
203
|
+
onDecision({ approved: false, reason: reason.trim() || undefined });
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
Deny
|
|
207
|
+
</Action>
|
|
208
|
+
<Action
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setDenying(false);
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
Back
|
|
214
|
+
</Action>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
) : (
|
|
218
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
219
|
+
<Action
|
|
220
|
+
variant="primary"
|
|
221
|
+
disabled={!settled}
|
|
222
|
+
onClick={() => {
|
|
223
|
+
onDecision({ approved: true, scope: "once", arguments: values, edited });
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
{edited.length > 0 ? "Approve with changes" : "Approve once"}
|
|
227
|
+
</Action>
|
|
228
|
+
|
|
229
|
+
{/* Scope is a separate decision from approval, and blanket consent
|
|
230
|
+
should never be the easiest button to reach. */}
|
|
231
|
+
<Action
|
|
232
|
+
disabled={!settled}
|
|
233
|
+
onClick={() => {
|
|
234
|
+
onDecision({ approved: true, scope: "always", arguments: values, edited });
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
Always allow {tool}
|
|
238
|
+
</Action>
|
|
239
|
+
|
|
240
|
+
<Action
|
|
241
|
+
onClick={() => {
|
|
242
|
+
setDenying(true);
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
Deny…
|
|
246
|
+
</Action>
|
|
247
|
+
|
|
248
|
+
{!settled ? (
|
|
249
|
+
<span aria-live="polite" className="text-xs text-muted-foreground">
|
|
250
|
+
Waiting for the model to finish the request
|
|
251
|
+
</span>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</section>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ApprovalArgument({
|
|
260
|
+
field,
|
|
261
|
+
value,
|
|
262
|
+
arrived,
|
|
263
|
+
changed,
|
|
264
|
+
onChange,
|
|
265
|
+
}: {
|
|
266
|
+
field: ApprovalField;
|
|
267
|
+
value: string;
|
|
268
|
+
arrived: boolean;
|
|
269
|
+
changed: boolean;
|
|
270
|
+
onChange: (value: string) => void;
|
|
271
|
+
}) {
|
|
272
|
+
const id = useId();
|
|
273
|
+
const changedId = useId();
|
|
274
|
+
const editable = !field.readOnly && arrived;
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div
|
|
278
|
+
data-slot="approval-argument"
|
|
279
|
+
data-changed={changed || undefined}
|
|
280
|
+
className="flex flex-col gap-1"
|
|
281
|
+
>
|
|
282
|
+
<dt className="flex items-baseline gap-1">
|
|
283
|
+
<label htmlFor={editable ? id : undefined} className="text-xs text-muted-foreground">
|
|
284
|
+
{field.label}
|
|
285
|
+
</label>
|
|
286
|
+
{/* Outside the label, and attached as a description instead. Inside it,
|
|
287
|
+
editing a field would rename the field — a screen reader user would
|
|
288
|
+
hear its accessible name change under them mid-edit. Marked in text
|
|
289
|
+
rather than by border colour alone, because an audit trail has to
|
|
290
|
+
tell the model's proposal from the human's correction. */}
|
|
291
|
+
{changed ? (
|
|
292
|
+
<span id={changedId} className="text-xs text-warning">
|
|
293
|
+
· changed from the model’s proposal
|
|
294
|
+
</span>
|
|
295
|
+
) : null}
|
|
296
|
+
</dt>
|
|
297
|
+
<dd className="m-0">
|
|
298
|
+
{!arrived ? (
|
|
299
|
+
<span
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
className="block h-8 w-full animate-pulse-soft rounded-md bg-muted"
|
|
302
|
+
/>
|
|
303
|
+
) : editable ? (
|
|
304
|
+
field.multiline ? (
|
|
305
|
+
<textarea
|
|
306
|
+
id={id}
|
|
307
|
+
aria-describedby={changed ? changedId : undefined}
|
|
308
|
+
rows={3}
|
|
309
|
+
value={value}
|
|
310
|
+
onChange={(event) => {
|
|
311
|
+
onChange(event.target.value);
|
|
312
|
+
}}
|
|
313
|
+
className={cn(
|
|
314
|
+
"w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs",
|
|
315
|
+
changed && "border-warning",
|
|
316
|
+
focusRing,
|
|
317
|
+
)}
|
|
318
|
+
/>
|
|
319
|
+
) : (
|
|
320
|
+
<input
|
|
321
|
+
id={id}
|
|
322
|
+
type="text"
|
|
323
|
+
aria-describedby={changed ? changedId : undefined}
|
|
324
|
+
value={value}
|
|
325
|
+
onChange={(event) => {
|
|
326
|
+
onChange(event.target.value);
|
|
327
|
+
}}
|
|
328
|
+
className={cn(
|
|
329
|
+
"w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs",
|
|
330
|
+
changed && "border-warning",
|
|
331
|
+
focusRing,
|
|
332
|
+
)}
|
|
333
|
+
/>
|
|
334
|
+
)
|
|
335
|
+
) : (
|
|
336
|
+
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-xs break-words">
|
|
337
|
+
{value}
|
|
338
|
+
</p>
|
|
339
|
+
)}
|
|
340
|
+
</dd>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function Action({
|
|
346
|
+
className,
|
|
347
|
+
variant = "default",
|
|
348
|
+
...props
|
|
349
|
+
}: ComponentPropsWithRef<"button"> & { variant?: "default" | "primary" | "destructive" }) {
|
|
350
|
+
return (
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
className={cn(
|
|
354
|
+
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
355
|
+
variant === "primary" &&
|
|
356
|
+
"border-primary bg-primary text-primary-foreground hover:bg-primary-hover",
|
|
357
|
+
variant === "destructive" &&
|
|
358
|
+
"border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
359
|
+
variant === "default" &&
|
|
360
|
+
"border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
361
|
+
focusRing,
|
|
362
|
+
disabledStyles,
|
|
363
|
+
className,
|
|
364
|
+
)}
|
|
365
|
+
{...props}
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "ai-approval-request",
|
|
5
|
+
title: "AI Approval Request",
|
|
6
|
+
description: "Approve a tool call — after correcting the arguments the model got wrong.",
|
|
7
|
+
category: "ai",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: [],
|
|
10
|
+
registryDependencies: [],
|
|
11
|
+
files: ["ai-approval-request.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"The proposed arguments are a description list of real labelled form controls, so each value " +
|
|
14
|
+
"is associated with its name and editable ones are reachable by keyboard. A corrected " +
|
|
15
|
+
"argument is marked in text rather than by border colour alone, because an audit trail has " +
|
|
16
|
+
"to distinguish the model's proposal from the human's edit. The request is aria-busy while " +
|
|
17
|
+
"arguments are still arriving and the decision controls stay disabled until it is whole — " +
|
|
18
|
+
"rendering nothing until then, as the common implementation does, means nothing is on screen " +
|
|
19
|
+
"at the moment approval becomes relevant. Irreversibility is stated as a sentence, never as a " +
|
|
20
|
+
"severity colour.",
|
|
21
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ChangeEvent,
|
|
8
|
+
type ComponentPropsWithRef,
|
|
9
|
+
type CompositionEvent,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
type UIEvent,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ghost-text suggestion inside a real textarea or input.
|
|
18
|
+
*
|
|
19
|
+
* Outside chat this is the most-copied AI affordance in software — Smart
|
|
20
|
+
* Compose, Copilot's grey continuation, Notion and Linear's inline suggestions —
|
|
21
|
+
* and every team rebuilds the same overlay-mirror trick from the same handful of
|
|
22
|
+
* blog posts.
|
|
23
|
+
*
|
|
24
|
+
* Two deliberate limits, both stated rather than discovered:
|
|
25
|
+
*
|
|
26
|
+
* 1. It works on `textarea` and `input`, not on contenteditable. Ghost text in
|
|
27
|
+
* contenteditable means owning selection, undo and inline formatting, which
|
|
28
|
+
* is an editor, not a component. Anyone in that territory already has
|
|
29
|
+
* ProseMirror or Lexical and should extend it there.
|
|
30
|
+
* 2. The suggestion completes the END of the value. Mid-caret insertion needs
|
|
31
|
+
* per-character measurement of wrapped text; the ghost hides whenever the
|
|
32
|
+
* caret is not at the end rather than rendering in the wrong place.
|
|
33
|
+
*
|
|
34
|
+
* There is no WAI-ARIA pattern for generative ghost text. Combobox is the
|
|
35
|
+
* nearest and does not fit: the suggestion is not one of a known set of options,
|
|
36
|
+
* so a listbox would be a lie. What the standard does require is that the
|
|
37
|
+
* suggestion be perceivable and that the keyboard is never trapped — so the text
|
|
38
|
+
* is announced through a live description, and Escape always restores plain Tab.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export interface InlineCompletionProps extends Omit<
|
|
42
|
+
ComponentPropsWithRef<"textarea">,
|
|
43
|
+
"value" | "onChange" | "children"
|
|
44
|
+
> {
|
|
45
|
+
value: string;
|
|
46
|
+
onValueChange: (value: string) => void;
|
|
47
|
+
/**
|
|
48
|
+
* The continuation to show after the value. Empty or undefined shows nothing.
|
|
49
|
+
* The caller supplies this; the component never requests it.
|
|
50
|
+
*/
|
|
51
|
+
suggestion?: string;
|
|
52
|
+
/** Called with the full text after the suggestion is taken. */
|
|
53
|
+
onAccept?: (value: string) => void;
|
|
54
|
+
/** Called when the reader rejects the suggestion outright. */
|
|
55
|
+
onDismiss?: () => void;
|
|
56
|
+
/** Renders a single-line input instead of a textarea. */
|
|
57
|
+
singleLine?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The next word of a suggestion, including its leading space. */
|
|
61
|
+
function nextWord(suggestion: string): string {
|
|
62
|
+
const match = /^\s*\S+/.exec(suggestion);
|
|
63
|
+
return match ? match[0] : suggestion;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function InlineCompletion({
|
|
67
|
+
className,
|
|
68
|
+
value,
|
|
69
|
+
onValueChange,
|
|
70
|
+
suggestion = "",
|
|
71
|
+
onAccept,
|
|
72
|
+
onDismiss,
|
|
73
|
+
singleLine = false,
|
|
74
|
+
onKeyDown,
|
|
75
|
+
onScroll,
|
|
76
|
+
...props
|
|
77
|
+
}: InlineCompletionProps) {
|
|
78
|
+
const fieldRef = useRef<HTMLTextAreaElement | null>(null);
|
|
79
|
+
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
|
80
|
+
const describedById = useId();
|
|
81
|
+
|
|
82
|
+
const [dismissed, setDismissed] = useState(false);
|
|
83
|
+
const [composing, setComposing] = useState(false);
|
|
84
|
+
const [caretAtEnd, setCaretAtEnd] = useState(true);
|
|
85
|
+
|
|
86
|
+
// A suggestion is only shown when it can be shown honestly: not mid-word of
|
|
87
|
+
// an IME composition, not after the reader has rejected it, and not when the
|
|
88
|
+
// caret sits somewhere the ghost would render in the wrong place.
|
|
89
|
+
const visible = suggestion.length > 0 && !dismissed && !composing && caretAtEnd;
|
|
90
|
+
|
|
91
|
+
function syncCaret() {
|
|
92
|
+
const field = fieldRef.current;
|
|
93
|
+
if (!field) return;
|
|
94
|
+
setCaretAtEnd(field.selectionStart === value.length && field.selectionEnd === value.length);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function accept(text: string) {
|
|
98
|
+
const next = value + text;
|
|
99
|
+
onValueChange(next);
|
|
100
|
+
onAccept?.(next);
|
|
101
|
+
setDismissed(false);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
105
|
+
onKeyDown?.(event);
|
|
106
|
+
if (event.defaultPrevented) return;
|
|
107
|
+
|
|
108
|
+
if (!visible) return;
|
|
109
|
+
|
|
110
|
+
// Word-at-a-time, before the plain Tab case so the modifier wins.
|
|
111
|
+
if (event.key === "ArrowRight" && (event.metaKey || event.altKey || event.ctrlKey)) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
accept(nextWord(suggestion));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (event.key === "Tab") {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
accept(suggestion);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (event.key === "Escape") {
|
|
124
|
+
// The escape hatch. Without it, a keyboard user facing a suggestion has
|
|
125
|
+
// no way to leave the field: Tab would accept instead of moving focus.
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
setDismissed(true);
|
|
128
|
+
onDismiss?.();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
|
133
|
+
// Any edit invalidates the previous rejection: the reader has moved on and
|
|
134
|
+
// the next suggestion is a different one.
|
|
135
|
+
setDismissed(false);
|
|
136
|
+
onValueChange(event.target.value);
|
|
137
|
+
queueMicrotask(syncCaret);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleScroll(event: UIEvent<HTMLTextAreaElement>) {
|
|
141
|
+
onScroll?.(event);
|
|
142
|
+
// The mirror has to follow the field, or the ghost detaches from the text
|
|
143
|
+
// as soon as the content is taller than the box.
|
|
144
|
+
if (mirrorRef.current) {
|
|
145
|
+
mirrorRef.current.scrollTop = event.currentTarget.scrollTop;
|
|
146
|
+
mirrorRef.current.scrollLeft = event.currentTarget.scrollLeft;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleComposition(event: CompositionEvent<HTMLTextAreaElement>) {
|
|
151
|
+
setComposing(event.type === "compositionstart");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Typography must match exactly between field and mirror or the ghost lands
|
|
155
|
+
// in the wrong place. Both take the same class string for that reason.
|
|
156
|
+
const shared = cn(
|
|
157
|
+
"w-full rounded-md border border-input px-3 py-2 text-sm",
|
|
158
|
+
singleLine ? "overflow-x-auto whitespace-pre" : "break-words whitespace-pre-wrap",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div data-slot="inline-completion" className={cn("relative", className)}>
|
|
163
|
+
{/* The mirror sits behind the field and holds the value in transparent
|
|
164
|
+
text purely to push the ghost to the caret position. */}
|
|
165
|
+
<div
|
|
166
|
+
ref={mirrorRef}
|
|
167
|
+
aria-hidden="true"
|
|
168
|
+
data-slot="inline-completion-ghost"
|
|
169
|
+
className={cn(
|
|
170
|
+
shared,
|
|
171
|
+
"pointer-events-none absolute inset-0 overflow-hidden border-transparent text-transparent select-none",
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{value}
|
|
175
|
+
{visible ? (
|
|
176
|
+
<span data-slot="inline-completion-suggestion" className="text-muted-foreground">
|
|
177
|
+
{suggestion}
|
|
178
|
+
</span>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<textarea
|
|
183
|
+
ref={fieldRef}
|
|
184
|
+
data-slot="inline-completion-field"
|
|
185
|
+
data-suggesting={visible || undefined}
|
|
186
|
+
rows={singleLine ? 1 : props.rows}
|
|
187
|
+
value={value}
|
|
188
|
+
onChange={handleChange}
|
|
189
|
+
onKeyDown={handleKeyDown}
|
|
190
|
+
onScroll={handleScroll}
|
|
191
|
+
onSelect={syncCaret}
|
|
192
|
+
onClick={syncCaret}
|
|
193
|
+
onCompositionStart={handleComposition}
|
|
194
|
+
onCompositionEnd={handleComposition}
|
|
195
|
+
aria-describedby={visible ? describedById : undefined}
|
|
196
|
+
className={cn(
|
|
197
|
+
shared,
|
|
198
|
+
"relative resize-none bg-transparent",
|
|
199
|
+
"placeholder:text-muted-foreground",
|
|
200
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring/55",
|
|
201
|
+
singleLine && "resize-none",
|
|
202
|
+
)}
|
|
203
|
+
{...props}
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
{/* The suggestion as text, for anyone who cannot see grey glyphs behind a
|
|
207
|
+
field. Polite, so it does not interrupt typing, and it states the keys
|
|
208
|
+
because a gesture nobody knows about is not an affordance. */}
|
|
209
|
+
<span id={describedById} aria-live="polite" className="sr-only">
|
|
210
|
+
{visible ? `Suggestion: ${suggestion}. Press Tab to accept, Escape to dismiss.` : ""}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { InlineCompletion, type InlineCompletionProps } from "./ai-inline-completion";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "ai-inline-completion",
|
|
5
|
+
title: "AI Inline Completion",
|
|
6
|
+
description: "Ghost-text suggestion inside a real textarea, accepted with Tab.",
|
|
7
|
+
category: "ai",
|
|
8
|
+
status: "beta",
|
|
9
|
+
dependencies: [],
|
|
10
|
+
registryDependencies: [],
|
|
11
|
+
files: ["ai-inline-completion.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"No WAI-ARIA pattern covers generative ghost text — combobox is the nearest and does not fit, " +
|
|
14
|
+
"because the suggestion is not one of a known set of options and a listbox would misdescribe " +
|
|
15
|
+
"it. So the suggestion is announced through a polite live description that names the keys, " +
|
|
16
|
+
"since a gesture nobody knows about is not an affordance, and the grey text itself is " +
|
|
17
|
+
"aria-hidden. Escape always dismisses and restores plain Tab, so the keyboard is never " +
|
|
18
|
+
"trapped in the field. The ghost hides during IME composition and whenever the caret is not " +
|
|
19
|
+
"at the end, rather than rendering somewhere it does not belong.",
|
|
20
|
+
});
|
|
@@ -50,6 +50,7 @@ export function ResponseCaret({ className, ...props }: ComponentPropsWithRef<"sp
|
|
|
50
50
|
<span
|
|
51
51
|
data-slot="response-caret"
|
|
52
52
|
aria-hidden="true"
|
|
53
|
+
data-motion="indicator"
|
|
53
54
|
className={cn(
|
|
54
55
|
"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-caret bg-current align-baseline",
|
|
55
56
|
className,
|