@superxd-studio/adptr 0.5.0 → 0.6.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 (43) hide show
  1. package/GENERATIVE.md +1 -1
  2. package/README.md +20 -11
  3. package/REUSE.md +11 -1
  4. package/REVIEW.md +2 -2
  5. package/THIRD-PARTY-NOTICES.md +1 -1
  6. package/chunks/{callout-DKMHSnjc.js → callout-B89MDU9m.js} +31 -35
  7. package/chunks/{canvas-BJ15-XxE.js → canvas-Dd78FPe0.js} +9 -9
  8. package/chunks/{text-field-BLtoaZFn.js → text-field-mMHllx03.js} +1 -1
  9. package/chunks/thinking-DlBmZsgc.js +13 -0
  10. package/chunks/{ui-9DyNsYXY.js → ui-WR0qTNm7.js} +309 -260
  11. package/generative/index.js +156 -155
  12. package/interactions/index.js +1073 -499
  13. package/package.json +2 -1
  14. package/source/components/icons.tsx +14 -0
  15. package/source/components/peek.tsx +113 -0
  16. package/source/components/thinking.tsx +13 -2
  17. package/source/reusable/generative/index.tsx +14 -9
  18. package/source/reusable/generative/vocabulary.tsx +4 -2
  19. package/source/reusable/interactions/context.tsx +4 -0
  20. package/source/reusable/interactions/copy.ts +39 -0
  21. package/source/reusable/interactions/gate.tsx +246 -0
  22. package/source/reusable/interactions/index.ts +14 -0
  23. package/source/reusable/interactions/presentation.tsx +4 -1
  24. package/source/reusable/interactions/progress.tsx +240 -0
  25. package/source/reusable/interactions/receipt.tsx +230 -0
  26. package/source/reusable/interactions/run.tsx +206 -0
  27. package/source/reusable/ui/index.ts +1 -0
  28. package/source/reusable/web/review/index.tsx +21 -10
  29. package/source/styles/library.css +56 -0
  30. package/styles.css +1 -1
  31. package/types/components/icons.d.ts +8 -0
  32. package/types/components/peek.d.ts +17 -0
  33. package/types/components/thinking.d.ts +3 -1
  34. package/types/reusable/interactions/context.d.ts +39 -0
  35. package/types/reusable/interactions/copy.d.ts +43 -0
  36. package/types/reusable/interactions/gate.d.ts +39 -0
  37. package/types/reusable/interactions/index.d.ts +8 -0
  38. package/types/reusable/interactions/progress.d.ts +45 -0
  39. package/types/reusable/interactions/receipt.d.ts +54 -0
  40. package/types/reusable/interactions/run.d.ts +83 -0
  41. package/types/reusable/ui/index.d.ts +1 -0
  42. package/ui/index.js +3 -3
  43. package/web/index.js +350 -353
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superxd-studio/adptr",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "ADPTR by SuperXD: React components and finished product flows for conversation, review and AI-assisted work, with every state designed.",
@@ -108,6 +108,7 @@
108
108
  "clsx": "^2.1.1",
109
109
  "tailwind-merge": "^3.7.0",
110
110
  "react-aria-components": "^1.21.1",
111
+ "react-aria": "3.52.1",
111
112
  "react-markdown": ">=10.0.0 <=10.1.0",
112
113
  "remark-gfm": ">=4.0.0 <=4.0.1",
113
114
  "@internationalized/date": "^3.12.4",
@@ -136,6 +136,20 @@ export const IconEdit = createIcon(
136
136
  </>,
137
137
  );
138
138
  export const IconCheck = createIcon("Check", <path d="M20 6 9 17l-5-5" />);
139
+ export const IconBookOpen = createIcon(
140
+ "BookOpen",
141
+ <>
142
+ <path d="M12 7v14" />
143
+ <path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
144
+ </>,
145
+ );
146
+ export const IconTerminal = createIcon(
147
+ "Terminal",
148
+ <>
149
+ <path d="M12 19h8" />
150
+ <path d="m4 17 6-6-6-6" />
151
+ </>,
152
+ );
139
153
 
140
154
  export const IconX = createIcon(
141
155
  "X",
@@ -0,0 +1,113 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { mergeProps, useButton, useFocusRing, useHover } from "react-aria";
3
+ import { Dialog, Popover } from "react-aria-components";
4
+ import { PortalTheme } from "./ui/portal-theme.js";
5
+ import { cn } from "../lib/cn.js";
6
+
7
+ /** How long the pointer rests on a word before its detail peeks open, and lingers after */
8
+ const OPEN_MS = 280;
9
+ const CLOSE_MS = 160;
10
+
11
+ /**
12
+ * A word in a sentence with more behind it, like an explorable explanation or the hover card
13
+ * behind an @mention. Resting the pointer on it peeks the detail open; pressing it (click,
14
+ * tap, Enter or Space) keeps it open as a small dialog, closed with Escape or by pressing
15
+ * elsewhere. The dotted underline is the only hint, so the sentence still reads as text.
16
+ *
17
+ * A file looks like a link, as in VS Code: ultramarine and monospaced, and it opens when pressed
18
+ * rather than when the pointer rests on it.
19
+ */
20
+ export function Peek({
21
+ label,
22
+ title,
23
+ look = "word",
24
+ children,
25
+ }: {
26
+ label: ReactNode;
27
+ /** What the detail is, for screen readers */
28
+ title: string;
29
+ look?: "word" | "file";
30
+ children: ReactNode | ((close: () => void) => ReactNode);
31
+ }) {
32
+ const hovers = look === "word";
33
+ // A span, not a <button>, so a long phrase wraps with the sentence like ordinary text
34
+ const trigger = useRef<HTMLSpanElement>(null);
35
+ const timer = useRef<number | undefined>(undefined);
36
+ const [open, setOpen] = useState(false);
37
+ const [pinned, setPinned] = useState(false);
38
+ useEffect(() => () => window.clearTimeout(timer.current), []);
39
+ const later = (next: boolean, ms: number) => {
40
+ window.clearTimeout(timer.current);
41
+ timer.current = window.setTimeout(() => setOpen(next), ms);
42
+ };
43
+ const close = () => {
44
+ window.clearTimeout(timer.current);
45
+ setOpen(false);
46
+ setPinned(false);
47
+ };
48
+ const { buttonProps } = useButton(
49
+ {
50
+ elementType: "span",
51
+ "aria-expanded": open,
52
+ onPress: () => {
53
+ window.clearTimeout(timer.current);
54
+ if (pinned) return close();
55
+ setPinned(true);
56
+ setOpen(true);
57
+ },
58
+ },
59
+ trigger,
60
+ );
61
+ const { hoverProps, isHovered } = useHover({
62
+ onHoverStart: () => hovers && !pinned && later(true, OPEN_MS),
63
+ onHoverEnd: () => hovers && !pinned && later(false, CLOSE_MS),
64
+ });
65
+ const { focusProps, isFocusVisible } = useFocusRing();
66
+ return (
67
+ <PortalTheme>
68
+ {(theme) => (
69
+ <>
70
+ <span
71
+ ref={trigger}
72
+ {...mergeProps(buttonProps, hoverProps, focusProps)}
73
+ data-hovered={isHovered || undefined}
74
+ data-focus-visible={isFocusVisible || undefined}
75
+ className={cn(
76
+ "cursor-pointer rounded-cell wrap-anywhere outline-none data-focus-visible:focus-ring",
77
+ look === "word"
78
+ ? "font-medium text-fg-emphasis underline decoration-line-emphasis decoration-dotted decoration-[1.5px] underline-offset-[5px] transition-[text-decoration-color] duration-(--duration-snap) ease-snap data-hovered:decoration-fg-subtle"
79
+ : "font-mono text-done underline-offset-[3px] data-hovered:underline",
80
+ )}
81
+ >
82
+ {label}
83
+ </span>
84
+ <Popover
85
+ triggerRef={trigger}
86
+ isOpen={open}
87
+ onOpenChange={(next) => (next ? setOpen(true) : close())}
88
+ // A peek doesn't take focus or block the page; a pressed word opens a real dialog
89
+ isNonModal={!pinned}
90
+ placement="bottom start"
91
+ offset={8}
92
+ style={theme.style}
93
+ className={cn(
94
+ theme.className,
95
+ // Long details scroll inside the peek rather than running off the screen
96
+ "z-50 max-h-[min(24rem,60vh)] w-80 max-w-[calc(100vw-2rem)] origin-(--trigger-anchor-point) overflow-y-auto rounded-surface border border-line-subtle bg-surface p-4 wrap-anywhere text-fg shadow-popover outline-none data-entering:animate-pop-in data-exiting:animate-pop-out",
97
+ )}
98
+ >
99
+ <Dialog aria-label={title} className="outline-none">
100
+ {/* Moving the pointer into the detail keeps it open */}
101
+ <div
102
+ onPointerEnter={() => !pinned && window.clearTimeout(timer.current)}
103
+ onPointerLeave={() => !pinned && later(false, CLOSE_MS)}
104
+ >
105
+ {typeof children === "function" ? children(close) : children}
106
+ </div>
107
+ </Dialog>
108
+ </Popover>
109
+ </>
110
+ )}
111
+ </PortalTheme>
112
+ );
113
+ }
@@ -5,9 +5,20 @@ import { cn } from "../lib/cn.js";
5
5
  * length (an AI preparing an answer). About the height of a line of text. Decorative: say
6
6
  * what's happening in words beside it.
7
7
  */
8
- export function Thinking({ className }: { className?: string }) {
8
+ export function Thinking({
9
+ className,
10
+ tone = "brand",
11
+ }: {
12
+ className?: string;
13
+ /** brand (ultramarine) while the system works; info (calm blue) while it waits on a person */
14
+ tone?: "brand" | "info";
15
+ }) {
9
16
  return (
10
- <span aria-hidden="true" className={cn("thinking-matrix", className)}>
17
+ <span
18
+ aria-hidden="true"
19
+ data-tone={tone === "info" ? "info" : undefined}
20
+ className={cn("thinking-matrix", className)}
21
+ >
11
22
  {Array.from({ length: 9 }, (_, i) => (
12
23
  <i key={i} />
13
24
  ))}
@@ -13,6 +13,7 @@ import {
13
13
  import { renderGenerativeUI } from "@assistant-ui/react-generative-ui";
14
14
  import { Button } from "../../components/button.js";
15
15
  import { Callout } from "../../components/callout.js";
16
+ import { IconArrowLeft } from "../../components/icons.js";
16
17
  import { Spinner } from "../../components/spinner.js";
17
18
  import { generativeCopy as defaultCopy, type GenerativeCopy } from "./copy.js";
18
19
  import { parseStructuredSpec, type StructuredSpec } from "./schema.js";
@@ -390,10 +391,11 @@ function ResponseView({
390
391
  {!retainSteps ? (
391
392
  <Button
392
393
  size="sm"
393
- variant="ghost"
394
+ variant="secondary"
394
395
  className="self-start"
395
396
  onPress={() => controller.reset()}
396
397
  >
398
+ <IconArrowLeft />
397
399
  {copy.back}
398
400
  </Button>
399
401
  ) : null}
@@ -428,9 +430,19 @@ function ResponseView({
428
430
  ))}
429
431
  </dl>
430
432
  ) : null}
431
- <div className="flex flex-wrap gap-3">
433
+ {/* Back on the left, Confirm on the right */}
434
+ <div className="flex flex-wrap items-center gap-3">
435
+ <Button
436
+ variant="secondary"
437
+ isDisabled={snapshot.stage !== "confirm"}
438
+ onPress={() => controller.back()}
439
+ >
440
+ <IconArrowLeft />
441
+ {copy.back}
442
+ </Button>
432
443
  <Button
433
444
  variant="primary"
445
+ className="ml-auto"
434
446
  isDisabled={snapshot.stage !== "confirm"}
435
447
  aria-busy={snapshot.stage !== "confirm"}
436
448
  aria-label={
@@ -449,13 +461,6 @@ function ResponseView({
449
461
  ? copy.working
450
462
  : copy.confirm}
451
463
  </Button>
452
- <Button
453
- variant="ghost"
454
- isDisabled={snapshot.stage !== "confirm"}
455
- onPress={() => controller.back()}
456
- >
457
- {copy.back}
458
- </Button>
459
464
  </div>
460
465
  {snapshot.stage !== "confirm" ? (
461
466
  <p role="status" className="sr-only">
@@ -820,8 +820,10 @@ function GeneratedButton({
820
820
  : "secondary"
821
821
  }
822
822
  className={cn(
823
- "h-auto min-h-control-md max-w-full py-control-y wrap-anywhere whitespace-normal",
824
- block && "w-full",
823
+ // Buttons fit their words and sit at the right end; a "block" button spans the width
824
+ // only on phones
825
+ "h-auto min-h-control-md max-w-full self-end py-control-y wrap-anywhere whitespace-normal",
826
+ block && "w-full sm:w-auto",
825
827
  )}
826
828
  isDisabled={
827
829
  blocked ||
@@ -32,6 +32,10 @@ export function InteractionProvider({
32
32
  agentStates: { ...parent.agentStates, ...copy.agentStates },
33
33
  artifactStates: { ...parent.artifactStates, ...copy.artifactStates },
34
34
  connection: { ...parent.connection, ...copy.connection },
35
+ gate: { ...parent.gate, ...copy.gate },
36
+ run: { ...parent.run, ...copy.run },
37
+ receipt: { ...parent.receipt, ...copy.receipt },
38
+ progress: { ...parent.progress, ...copy.progress },
35
39
  };
36
40
  return <CopyContext.Provider value={merged}>{children}</CopyContext.Provider>;
37
41
  }
@@ -114,4 +114,43 @@ export const interactionCopy = {
114
114
  noThreads: "No conversations in this view.",
115
115
  threadError: "Couldn’t complete that request. The current conversation is still here.",
116
116
  loading: "Loading",
117
+ /** The Decision Gate */
118
+ gate: {
119
+ review: "Review",
120
+ approve: "Approve once",
121
+ approving: "Approving",
122
+ decline: "Decline",
123
+ edit: "Edit before approving",
124
+ irreversible: "Can't be undone",
125
+ waiting: "Waiting for your approval",
126
+ changed: "Something changed while you were deciding",
127
+ reviewChange: "Review the change",
128
+ reconsider: "Reconsider",
129
+ },
130
+ /** The Action Receipt */
131
+ receipt: {
132
+ whatChanged: "What changed",
133
+ checking: "Checking what happened",
134
+ states: {
135
+ changed: "Changed",
136
+ "not-changed": "Not changed",
137
+ unknown: "Not known yet",
138
+ reverted: "Reverted",
139
+ },
140
+ minutesLeft: (minutes: number) => (minutes === 1 ? "1 min left" : `${minutes} min left`),
141
+ closed: "Undo has closed",
142
+ },
143
+ /** Agent task progress */
144
+ progress: {
145
+ showStages: "Show stages",
146
+ stagesDone: (done: number, total: number) => `${done} of ${total} stages`,
147
+ noAction: "No action needed",
148
+ review: "Review",
149
+ stageWords: { done: "done", now: "now", next: "to come" },
150
+ },
151
+ /** An agent's run */
152
+ run: {
153
+ number: (value: number) => new Intl.NumberFormat("en").format(value),
154
+ lines: (added: string, removed: string) => `${added} lines added, ${removed} removed`,
155
+ },
117
156
  } as const;
@@ -0,0 +1,246 @@
1
+ import { useId, useState, type ReactNode } from "react";
2
+ import { Button } from "../../components/button.js";
3
+ import { IconAlert, IconCheck, IconChevronDown, IconEdit, IconX } from "../../components/icons.js";
4
+ import { Thinking } from "../../components/thinking.js";
5
+ import { Tooltip } from "../../components/ui/navigation.js";
6
+ import { cn } from "../../lib/cn.js";
7
+ import { useInteractionCopy } from "./context.js";
8
+ import { RunEntry, useInRun } from "./run.js";
9
+
10
+ /** Where the request is: waiting for the person, being carried out, or settled */
11
+ export type DecisionGateStatus = "waiting" | "approving" | "approved" | "declined";
12
+
13
+ /**
14
+ * Text buttons whose words can wrap when text is very large or the space very narrow, between
15
+ * words rather than inside them
16
+ */
17
+ const wraps = "h-auto min-h-control-sm whitespace-normal break-words py-1.5 text-center";
18
+
19
+ export interface DecisionGateProps {
20
+ /** What the agent wants to do, in a few words. Closed, it's shortened to one line. */
21
+ title: string;
22
+ /** Approving can't be undone: a red mark beside the title, and a red tag when open */
23
+ irreversible?: boolean;
24
+ status?: DecisionGateStatus;
25
+ /** The title once approved, as a receipt */
26
+ approvedTitle?: string;
27
+ /** The title once declined */
28
+ declinedTitle?: string;
29
+ /** What will happen, in one sentence. Put details behind its words with Peek. */
30
+ children: ReactNode;
31
+ onApprove: () => void;
32
+ onDecline: () => void;
33
+ /** Shows the Edit button */
34
+ onEdit?: () => void;
35
+ /** The host's form for changing the request. While it's shown, it replaces the sentence. */
36
+ editor?: ReactNode;
37
+ /** Something changed while the person was deciding. Approval waits until they've seen it. */
38
+ changed?: ReactNode;
39
+ onReviewChange?: () => void;
40
+ /** Shows Reconsider once declined */
41
+ onReconsider?: () => void;
42
+ /** What the Approve button says while the request is carried out */
43
+ approvingLabel?: string;
44
+ defaultExpanded?: boolean;
45
+ }
46
+
47
+ /**
48
+ * The Decision Gate: what an agent wants to do, in one contained card. Closed, it's a tile with
49
+ * the pixel matrix, the title and Review. Review opens it like an accordion to the sentence and
50
+ * the decision. Nothing is approved from the closed card, so the person approves what they read.
51
+ *
52
+ * The interface asks; the product enforces. The host decides what each answer does and passes
53
+ * the status back.
54
+ */
55
+ export function DecisionGate(props: DecisionGateProps) {
56
+ const card = <GateCard {...props} />;
57
+ if (!useInRun()) return card;
58
+ const status = props.status ?? "waiting";
59
+ return (
60
+ <RunEntry
61
+ card
62
+ dot={status === "approved" ? "success" : status === "declined" ? "note" : "waiting"}
63
+ >
64
+ {card}
65
+ </RunEntry>
66
+ );
67
+ }
68
+
69
+ function GateCard({
70
+ title,
71
+ irreversible = false,
72
+ status = "waiting",
73
+ approvedTitle,
74
+ declinedTitle,
75
+ children,
76
+ onApprove,
77
+ onDecline,
78
+ onEdit,
79
+ editor,
80
+ changed,
81
+ onReviewChange,
82
+ onReconsider,
83
+ approvingLabel,
84
+ defaultExpanded = false,
85
+ }: DecisionGateProps) {
86
+ const copy = useInteractionCopy().gate;
87
+ const [open, setOpen] = useState(defaultExpanded);
88
+ const panelId = useId();
89
+ const approved = status === "approved";
90
+ const declined = status === "declined";
91
+ const settled = approved || declined;
92
+ const editing = editor !== undefined && editor !== null;
93
+ const expanded = !settled && (open || editing);
94
+ const deciding = status === "waiting" && !changed && !editing;
95
+ const heading = approved ? (approvedTitle ?? title) : declined ? (declinedTitle ?? title) : title;
96
+
97
+ return (
98
+ <div className="@container/gate overflow-hidden rounded-surface border border-line-subtle bg-surface-muted">
99
+ <p role="status" className="sr-only">
100
+ {approved
101
+ ? heading
102
+ : declined
103
+ ? heading
104
+ : status === "approving"
105
+ ? (approvingLabel ?? copy.approving)
106
+ : changed
107
+ ? copy.changed
108
+ : ""}
109
+ </p>
110
+ {/*
111
+ * Tile, title and Review in a row. With very large text in a narrow panel they stack.
112
+ */}
113
+ <div className="flex flex-wrap items-start gap-3 p-3">
114
+ {/* The matrix says it's waiting; screen readers hear it in words */}
115
+ <span
116
+ {...(settled ? { "aria-hidden": true } : { role: "img", "aria-label": copy.waiting })}
117
+ className={cn(
118
+ "flex size-10 shrink-0 items-center justify-center rounded-control border border-line-subtle bg-surface",
119
+ approved && "border-done bg-done text-on-done",
120
+ )}
121
+ >
122
+ {approved ? (
123
+ <IconCheck className="size-4 animate-done-in" />
124
+ ) : declined ? (
125
+ <IconX className="size-4 text-fg-subtle" />
126
+ ) : (
127
+ <Thinking tone="info" />
128
+ )}
129
+ </span>
130
+ {/*
131
+ * Closed, the card is just its title, on one line, ending in "…" if it's longer, and a
132
+ * red mark if it can't be undone. Review opens the title in full, with the rest. Screen
133
+ * readers always hear all of it.
134
+ */}
135
+ <div className="flex min-h-10 min-w-0 flex-1 basis-24 items-center gap-1.5">
136
+ <span
137
+ className={cn(
138
+ "min-w-0 type-label text-fg-emphasis",
139
+ expanded ? "text-pretty wrap-anywhere" : "truncate",
140
+ )}
141
+ >
142
+ {heading}
143
+ </span>
144
+ {irreversible && !settled && !expanded ? (
145
+ <span role="img" aria-label={copy.irreversible} className="flex shrink-0">
146
+ <IconAlert className="size-4 text-error" />
147
+ </span>
148
+ ) : null}
149
+ </div>
150
+ <span className="ml-auto flex min-h-10 shrink-0 items-center">
151
+ {settled ? (
152
+ declined && onReconsider ? (
153
+ <Button size="sm" className={wraps} onPress={onReconsider}>
154
+ {copy.reconsider}
155
+ </Button>
156
+ ) : null
157
+ ) : (
158
+ <Button
159
+ size="sm"
160
+ className={cn("group/trigger", wraps)}
161
+ aria-label={copy.review}
162
+ aria-expanded={expanded}
163
+ aria-controls={panelId}
164
+ isDisabled={editing}
165
+ onPress={() => setOpen((value) => !value)}
166
+ >
167
+ {/* In a narrow space, just the chevron */}
168
+ <span className="@max-sm/gate:hidden">{copy.review}</span>
169
+ <IconChevronDown className="transition-[rotate] duration-(--duration-settle) ease-settle group-aria-expanded/trigger:rotate-180 motion-reduce:transition-none" />
170
+ </Button>
171
+ )}
172
+ </span>
173
+ </div>
174
+
175
+ {changed && !settled ? (
176
+ <div className="flex flex-col gap-3 border-t border-line-subtle px-4 py-3">
177
+ <p className="type-body text-pretty text-attention">{changed}</p>
178
+ {onReviewChange ? (
179
+ <div className="flex justify-end">
180
+ <Button size="sm" className={wraps} onPress={onReviewChange}>
181
+ {copy.reviewChange}
182
+ </Button>
183
+ </div>
184
+ ) : null}
185
+ </div>
186
+ ) : null}
187
+
188
+ {expanded ? (
189
+ <div
190
+ id={panelId}
191
+ className="flex animate-reveal-in flex-col gap-4 border-t border-line-subtle bg-surface p-4"
192
+ >
193
+ {editing ? (
194
+ editor
195
+ ) : (
196
+ <>
197
+ {irreversible ? (
198
+ <span className="inline-flex items-center gap-1 self-start rounded-pill border border-error-line bg-error-subtle px-2 py-0.5 type-caption break-words text-error">
199
+ <IconAlert className="size-3.5 shrink-0" />
200
+ {copy.irreversible}
201
+ </span>
202
+ ) : null}
203
+ <div className="type-body text-pretty wrap-anywhere text-fg">{children}</div>
204
+ {/* The decision sits at the right end, Approve once furthest right */}
205
+ <div className="flex flex-wrap items-center justify-end gap-2">
206
+ {onEdit ? (
207
+ <Tooltip content={copy.edit}>
208
+ <Button
209
+ variant="secondary"
210
+ size="icon-sm"
211
+ aria-label={copy.edit}
212
+ onPress={onEdit}
213
+ isDisabled={!deciding}
214
+ >
215
+ <IconEdit />
216
+ </Button>
217
+ </Tooltip>
218
+ ) : null}
219
+ <Button
220
+ variant="secondary"
221
+ size="sm"
222
+ className={cn("text-error", wraps)}
223
+ onPress={onDecline}
224
+ isDisabled={!deciding}
225
+ >
226
+ {copy.decline}
227
+ </Button>
228
+ <Button
229
+ variant="primary"
230
+ size="sm"
231
+ className={wraps}
232
+ onPress={onApprove}
233
+ isDisabled={Boolean(changed)}
234
+ isPending={status === "approving"}
235
+ pendingLabel={approvingLabel ?? copy.approving}
236
+ >
237
+ {copy.approve}
238
+ </Button>
239
+ </div>
240
+ </>
241
+ )}
242
+ </div>
243
+ ) : null}
244
+ </div>
245
+ );
246
+ }
@@ -4,6 +4,20 @@ export * from "./feedback.js";
4
4
  export * from "./threads.js";
5
5
  export * from "./presentation.js";
6
6
  export * from "./web.js";
7
+ export { RunTimeline, RunStep, RunThought, RunMessage, RunWorking } from "./run.js";
8
+ export type { RunFile } from "./run.js";
9
+ export { DecisionGate } from "./gate.js";
10
+ export type { DecisionGateProps, DecisionGateStatus } from "./gate.js";
11
+ export { ActionReceipt } from "./receipt.js";
12
+ export type {
13
+ ActionReceiptProps,
14
+ ReceiptAction,
15
+ ReceiptChange,
16
+ ReceiptChangeState,
17
+ ReceiptOutcome,
18
+ } from "./receipt.js";
19
+ export { TaskProgress } from "./progress.js";
20
+ export type { TaskProgressMove, TaskProgressProps, TaskProgressStatus } from "./progress.js";
7
21
  export { interactionCopy } from "./copy.js";
8
22
  export { jsonSchema } from "./store.js";
9
23
 
@@ -333,7 +333,10 @@ export function ComposerTools({
333
333
  />
334
334
  )}{" "}
335
335
  {commands.length > 0 && onCommand && (
336
- <Menu label={copy.commands} items={commands} onAction={onCommand} />
336
+ // In a column, a wrapper lets the Commands button fit its words
337
+ <div>
338
+ <Menu label={copy.commands} items={commands} onAction={onCommand} />
339
+ </div>
337
340
  )}
338
341
  </div>
339
342
  );