@elabs-ai/components-ai 4.0.0 → 4.1.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/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/src/confirmation.tsx
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
AlertDescription,
|
|
6
|
+
AlertTitle,
|
|
7
|
+
APPROVAL_SCOPE_DESCRIPTION_KEYS,
|
|
8
|
+
Kbd,
|
|
9
|
+
Label,
|
|
10
|
+
RadioGroup,
|
|
11
|
+
RadioGroupItem,
|
|
12
|
+
StatusBadge,
|
|
13
|
+
Textarea,
|
|
14
|
+
useLocale,
|
|
15
|
+
type ApprovalOption,
|
|
16
|
+
} from "@elabs-ai/components-ui";
|
|
4
17
|
import { Button } from "@elabs-ai/components-ui";
|
|
5
18
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
19
|
import type { ToolUIPart } from "ai";
|
|
7
20
|
import type { ComponentProps, ReactNode } from "react";
|
|
8
|
-
import { createContext, useContext, useId, useMemo } from "react";
|
|
21
|
+
import { createContext, forwardRef, useContext, useId, useMemo, useState } from "react";
|
|
9
22
|
|
|
10
23
|
type ToolUIPartApproval =
|
|
11
24
|
| {
|
|
@@ -35,6 +48,15 @@ interface ConfirmationContextValue {
|
|
|
35
48
|
state: ToolUIPart["state"];
|
|
36
49
|
/** Default id wired from the card's `aria-labelledby` to `ConfirmationTitle`. */
|
|
37
50
|
titleId: string;
|
|
51
|
+
/**
|
|
52
|
+
* Shared draft reason text (#103) — written by `ApprovalCardReason`, read by
|
|
53
|
+
* `ApprovalCardOptions` at the moment a decision commits, so a typed
|
|
54
|
+
* explanation reaches `onConfirm`'s second argument without threading a
|
|
55
|
+
* prop between two sibling compound parts. Unused by the pre-existing
|
|
56
|
+
* binary `Confirmation*` parts, so their rendered output is unaffected.
|
|
57
|
+
*/
|
|
58
|
+
reason: string;
|
|
59
|
+
setReason: (reason: string) => void;
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null);
|
|
@@ -64,7 +86,11 @@ export const Confirmation = ({
|
|
|
64
86
|
...props
|
|
65
87
|
}: ConfirmationProps) => {
|
|
66
88
|
const titleId = useId();
|
|
67
|
-
const
|
|
89
|
+
const [reason, setReason] = useState("");
|
|
90
|
+
const contextValue = useMemo(
|
|
91
|
+
() => ({ approval, state, titleId, reason, setReason }),
|
|
92
|
+
[approval, state, titleId, reason],
|
|
93
|
+
);
|
|
68
94
|
|
|
69
95
|
if (!approval || state === "input-streaming" || state === "input-available") {
|
|
70
96
|
return null;
|
|
@@ -250,6 +276,12 @@ export const ConfirmationDeny = (props: ConfirmationDenyProps) => (
|
|
|
250
276
|
* interaction (research 11 §B.3). Same components, clearer name; the
|
|
251
277
|
* `Confirmation*` exports stay for the AI-Elements-shaped API (non-breaking).
|
|
252
278
|
* New code reaches for `ApprovalCard`.
|
|
279
|
+
*
|
|
280
|
+
* The `Confirmation*` family below is CLOSED — frozen at exactly the ten
|
|
281
|
+
* names aliased here. New parts (#103 and beyond) are declared as real
|
|
282
|
+
* `ApprovalCard*` implementations, never added to this alias block. See
|
|
283
|
+
* `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 4 and the
|
|
284
|
+
* `FROZEN_CONFIRMATION_EXPORTS` lock in `confirmation.test.tsx`.
|
|
253
285
|
*/
|
|
254
286
|
export const ApprovalCard = Confirmation;
|
|
255
287
|
export type ApprovalCardProps = ConfirmationProps;
|
|
@@ -271,3 +303,231 @@ export const ApprovalCardApprove = ConfirmationApprove;
|
|
|
271
303
|
export type ApprovalCardApproveProps = ConfirmationApproveProps;
|
|
272
304
|
export const ApprovalCardDeny = ConfirmationDeny;
|
|
273
305
|
export type ApprovalCardDenyProps = ConfirmationDenyProps;
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
* ---------------------------------------------------------------------------
|
|
309
|
+
* N-option, scoped approval (#103)
|
|
310
|
+
* ---------------------------------------------------------------------------
|
|
311
|
+
* Real coding-agent permission prompts are rarely a plain yes/no — they ask
|
|
312
|
+
* an N-option question whose options encode SCOPE: "Yes", "Yes, and don't ask
|
|
313
|
+
* again this session", "No, and tell the agent what to do instead". The
|
|
314
|
+
* binary `Confirmation`/`ApprovalCard` pair above cannot express the middle
|
|
315
|
+
* option; these three parts extend it additively, composed as CHILDREN of the
|
|
316
|
+
* existing card (`ApprovalCard` / `ApprovalCardRequest`), never as a
|
|
317
|
+
* replacement for it. Every new export here is a real `ApprovalCard*`
|
|
318
|
+
* implementation — see the closed-family note above the alias block.
|
|
319
|
+
*
|
|
320
|
+
* Vocabulary note: this is the PER-CALL decision surface ("may I run this
|
|
321
|
+
* command?"). `PermissionModeSelect` (#104) is the separate, standing POLICY
|
|
322
|
+
* surface ("how much may you do without asking?") — the two compose rather
|
|
323
|
+
* than merge into one boolean-flagged component, per
|
|
324
|
+
* `.claude/rules/component-api.md` ("Avoid boolean-prop proliferation").
|
|
325
|
+
*/
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* `ApprovalScope`, `ApprovalOption` and `APPROVAL_SCOPE_DESCRIPTION_KEYS`
|
|
329
|
+
* moved to `@elabs-ai/components-ui` (`lib/approval-option.ts`) — the
|
|
330
|
+
* terminal CLI look-alike family's own permission row (issue #117) reuses
|
|
331
|
+
* the same model, and `@elabs-ai/components-ai`/`@elabs-ai/components-terminal`
|
|
332
|
+
* are layer-2 DAG siblings that may not import each other (T0; see
|
|
333
|
+
* docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
|
|
334
|
+
* above.
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
export interface ApprovalCardOptionsProps extends ComponentProps<"div"> {
|
|
338
|
+
options: ApprovalOption[];
|
|
339
|
+
value?: string;
|
|
340
|
+
defaultValue?: string;
|
|
341
|
+
onValueChange?: (id: string) => void;
|
|
342
|
+
/**
|
|
343
|
+
* Fires when an option is selected — by click, or by ANY keyboard move
|
|
344
|
+
* within the radio group (Home/End/arrow keys select immediately, per the
|
|
345
|
+
* native/ARIA radiogroup pattern Radix implements; there is no separate
|
|
346
|
+
* "highlight vs commit" step). Reports the full resolved `ApprovalOption`
|
|
347
|
+
* plus whatever reason text is currently held by a sibling
|
|
348
|
+
* `ApprovalCardReason` — type a reason BEFORE choosing an option to have it
|
|
349
|
+
* included in this call.
|
|
350
|
+
*/
|
|
351
|
+
onConfirm?: (option: ApprovalOption, reason?: string) => void;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* ApprovalCardOptions — the N-option, scoped decision (#103). Renders through
|
|
356
|
+
* `@elabs-ai/components-ui`'s `RadioGroup` (Radix) for roving focus, arrow-key
|
|
357
|
+
* wrap, Home/End and announcement — never a hand-rolled
|
|
358
|
+
* `parentElement.children[i]` focus walk. Composed inside `ApprovalCardRequest`,
|
|
359
|
+
* alongside the existing `ApprovalCardTitle`/`ApprovalCardDescription`.
|
|
360
|
+
*/
|
|
361
|
+
export const ApprovalCardOptions = forwardRef<HTMLDivElement, ApprovalCardOptionsProps>(
|
|
362
|
+
function ApprovalCardOptions(
|
|
363
|
+
{
|
|
364
|
+
options,
|
|
365
|
+
value,
|
|
366
|
+
defaultValue,
|
|
367
|
+
onValueChange,
|
|
368
|
+
onConfirm,
|
|
369
|
+
className,
|
|
370
|
+
dir,
|
|
371
|
+
"aria-labelledby": ariaLabelledBy,
|
|
372
|
+
...props
|
|
373
|
+
},
|
|
374
|
+
ref,
|
|
375
|
+
) {
|
|
376
|
+
const { t } = useLocale();
|
|
377
|
+
const { titleId, reason } = useConfirmation();
|
|
378
|
+
const baseId = useId();
|
|
379
|
+
|
|
380
|
+
// Controlled/uncontrolled mirrors the platform (and `PermissionModeSelect`,
|
|
381
|
+
// #104): `value`/`onValueChange` for a controlled caller, `defaultValue`
|
|
382
|
+
// for an uncontrolled starting point — Radix's own `RadioGroup` already
|
|
383
|
+
// implements both correctly. Hand-rolling a parallel `internalValue`
|
|
384
|
+
// state here and always passing a (possibly `undefined`) `value` down
|
|
385
|
+
// makes Radix's controllable-state hook see a spurious
|
|
386
|
+
// uncontrolled-to-controlled transition the first time a selection is
|
|
387
|
+
// made, which is exactly the warning this avoids.
|
|
388
|
+
const handleValueChange = (id: string) => {
|
|
389
|
+
onValueChange?.(id);
|
|
390
|
+
const option = options.find((candidate) => candidate.id === id);
|
|
391
|
+
if (option) onConfirm?.(option, reason.trim() ? reason : undefined);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<RadioGroup
|
|
396
|
+
ref={ref}
|
|
397
|
+
// `dir` on a plain <div> is a free-form HTML string; Radix's
|
|
398
|
+
// RadioGroup narrows it to "ltr" | "rtl". Cast at this single call
|
|
399
|
+
// site, matching `PermissionModeSelect` (#104), rather than widening
|
|
400
|
+
// (and so weakening) the public prop type.
|
|
401
|
+
dir={dir as "ltr" | "rtl" | undefined}
|
|
402
|
+
defaultValue={defaultValue}
|
|
403
|
+
value={value}
|
|
404
|
+
onValueChange={handleValueChange}
|
|
405
|
+
aria-labelledby={ariaLabelledBy ?? titleId}
|
|
406
|
+
data-slot="approval-card-options"
|
|
407
|
+
className={cn("grid gap-2", className)}
|
|
408
|
+
{...props}
|
|
409
|
+
>
|
|
410
|
+
{options.map((option) => {
|
|
411
|
+
const itemId = `${baseId}-${option.id}`;
|
|
412
|
+
const descriptionId = `${itemId}-description`;
|
|
413
|
+
const description =
|
|
414
|
+
option.description ?? t(APPROVAL_SCOPE_DESCRIPTION_KEYS[option.scope]);
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div
|
|
418
|
+
key={option.id}
|
|
419
|
+
data-slot="approval-card-option"
|
|
420
|
+
data-scope={option.scope}
|
|
421
|
+
className={cn(
|
|
422
|
+
"flex items-start gap-3 rounded-lg border border-border bg-card p-3",
|
|
423
|
+
"has-[[data-state=checked]]:border-primary",
|
|
424
|
+
)}
|
|
425
|
+
>
|
|
426
|
+
<RadioGroupItem
|
|
427
|
+
aria-describedby={descriptionId}
|
|
428
|
+
className="mt-1"
|
|
429
|
+
data-slot="approval-card-option-input"
|
|
430
|
+
id={itemId}
|
|
431
|
+
value={option.id}
|
|
432
|
+
/>
|
|
433
|
+
<div className="grid flex-1 gap-1">
|
|
434
|
+
<div className="flex items-center justify-between gap-2">
|
|
435
|
+
<Label
|
|
436
|
+
className="text-body flex items-center gap-2 font-medium"
|
|
437
|
+
data-slot="approval-card-option-label"
|
|
438
|
+
htmlFor={itemId}
|
|
439
|
+
>
|
|
440
|
+
{option.label}
|
|
441
|
+
</Label>
|
|
442
|
+
{option.keyHint ? (
|
|
443
|
+
<Kbd data-slot="approval-card-option-key-hint">{option.keyHint}</Kbd>
|
|
444
|
+
) : null}
|
|
445
|
+
</div>
|
|
446
|
+
<p
|
|
447
|
+
className="text-meta text-muted-foreground"
|
|
448
|
+
data-slot="approval-card-option-description"
|
|
449
|
+
id={descriptionId}
|
|
450
|
+
>
|
|
451
|
+
{description}
|
|
452
|
+
</p>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
})}
|
|
457
|
+
</RadioGroup>
|
|
458
|
+
);
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
ApprovalCardOptions.displayName = "ApprovalCardOptions";
|
|
463
|
+
|
|
464
|
+
export type ApprovalCardTargetProps = ComponentProps<"div">;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* ApprovalCardTarget — the preview slot for WHAT is being approved: a
|
|
468
|
+
* command, a path, or a richer node such as a `DiffView` (#102). Accepts
|
|
469
|
+
* arbitrary children — this file never imports `DiffView`, the composition
|
|
470
|
+
* happens entirely in the consumer's tree. Ungated by state (unlike
|
|
471
|
+
* `ApprovalCardRequest`/`Accepted`/`Rejected`): the subject of a decision
|
|
472
|
+
* stays visible after it resolves, not only while pending.
|
|
473
|
+
*/
|
|
474
|
+
export const ApprovalCardTarget = forwardRef<HTMLDivElement, ApprovalCardTargetProps>(
|
|
475
|
+
function ApprovalCardTarget({ className, ...props }, ref) {
|
|
476
|
+
return (
|
|
477
|
+
<div
|
|
478
|
+
ref={ref}
|
|
479
|
+
data-slot="approval-card-target"
|
|
480
|
+
className={cn("text-body rounded-md bg-muted/50 p-3", className)}
|
|
481
|
+
{...props}
|
|
482
|
+
/>
|
|
483
|
+
);
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
ApprovalCardTarget.displayName = "ApprovalCardTarget";
|
|
488
|
+
|
|
489
|
+
export type ApprovalCardReasonProps = Omit<
|
|
490
|
+
ComponentProps<typeof Textarea>,
|
|
491
|
+
"value" | "defaultValue" | "onChange"
|
|
492
|
+
>;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* ApprovalCardReason — the deny-with-reason input, wired to the `reason`
|
|
496
|
+
* field that already exists on `ToolUIPartApproval` and was previously
|
|
497
|
+
* unreachable from the UI. Shares its value with `ApprovalCardOptions`
|
|
498
|
+
* through `Confirmation`'s own context (not a prop threaded between the two
|
|
499
|
+
* sibling parts) — type here BEFORE choosing an option to have the text
|
|
500
|
+
* included in that option's `onConfirm` call.
|
|
501
|
+
*/
|
|
502
|
+
export const ApprovalCardReason = forwardRef<HTMLTextAreaElement, ApprovalCardReasonProps>(
|
|
503
|
+
function ApprovalCardReason({ className, id, placeholder, ...props }, ref) {
|
|
504
|
+
const { t } = useLocale();
|
|
505
|
+
const { reason, setReason } = useConfirmation();
|
|
506
|
+
const generatedId = useId();
|
|
507
|
+
const textareaId = id ?? generatedId;
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
<div className="grid gap-1.5" data-slot="approval-card-reason">
|
|
511
|
+
<Label
|
|
512
|
+
className="text-meta text-muted-foreground"
|
|
513
|
+
data-slot="approval-card-reason-label"
|
|
514
|
+
htmlFor={textareaId}
|
|
515
|
+
>
|
|
516
|
+
{t("ai.approvalCard.reasonLabel")}
|
|
517
|
+
</Label>
|
|
518
|
+
<Textarea
|
|
519
|
+
ref={ref}
|
|
520
|
+
id={textareaId}
|
|
521
|
+
placeholder={placeholder ?? t("ai.approvalCard.reasonPlaceholder")}
|
|
522
|
+
value={reason}
|
|
523
|
+
onChange={(event) => setReason(event.target.value)}
|
|
524
|
+
data-slot="approval-card-reason-input"
|
|
525
|
+
className={cn("min-h-16 text-body", className)}
|
|
526
|
+
{...props}
|
|
527
|
+
/>
|
|
528
|
+
</div>
|
|
529
|
+
);
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
ApprovalCardReason.displayName = "ApprovalCardReason";
|
|
@@ -18,7 +18,15 @@ const meta = {
|
|
|
18
18
|
title: "AI/ContextPanel",
|
|
19
19
|
component: ContextPanel,
|
|
20
20
|
tags: ["autodocs"],
|
|
21
|
-
parameters: {
|
|
21
|
+
parameters: {
|
|
22
|
+
layout: "padded",
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"The chat workspace’s right context RAIL: produced assets, a preview drill-in, and an always-mounted width tween so an external `ContextPanelTrigger` can drive it from anywhere inside `ContextPanelProvider`. Nothing to do with `AI/TokenUsage`, the context-WINDOW usage ring, which carried the name `Context` until it was renamed for exactly this confusion; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Below the mobile breakpoint the rail degrades to a right-side `Sheet` driven by the same state.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
22
30
|
} satisfies Meta<typeof ContextPanel>;
|
|
23
31
|
export default meta;
|
|
24
32
|
type Story = StoryObj<typeof meta>;
|
package/src/context-panel.tsx
CHANGED
|
@@ -355,6 +355,7 @@ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(functi
|
|
|
355
355
|
) {
|
|
356
356
|
const { state, actions, meta } = useContextPanel();
|
|
357
357
|
const { panelId } = useContextPanelInternal();
|
|
358
|
+
const { t } = useLocale();
|
|
358
359
|
|
|
359
360
|
const panel = useCollapsiblePanel({
|
|
360
361
|
side: "right",
|
|
@@ -380,7 +381,7 @@ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(functi
|
|
|
380
381
|
style={{ "--context-panel-width": CONTEXT_PANEL_WIDTH_MOBILE } as CSSProperties}
|
|
381
382
|
>
|
|
382
383
|
<SheetHeader className="sr-only">
|
|
383
|
-
<SheetTitle>
|
|
384
|
+
<SheetTitle>{t("ai.contextPanel.title")}</SheetTitle>
|
|
384
385
|
<SheetDescription>Displays the chat context panel.</SheetDescription>
|
|
385
386
|
</SheetHeader>
|
|
386
387
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import {
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { Conversation, ConversationContent, ConversationEmptyState } from "./conversation";
|
|
3
4
|
import { Message, MessageContent } from "./message";
|
|
4
5
|
const meta = {
|
|
5
6
|
title: "AI/Conversation",
|
|
6
7
|
component: Conversation,
|
|
7
|
-
parameters: {
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "padded",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The CHAT scrolling transcript region — stick-to-bottom, with a scroll-to-bottom button once the reader leaves the end. The console counterpart is `Terminal/TerminalSurface`, and the frame around either is `AI/ChatShell` or `Terminal/TerminalConsole`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
8
17
|
} satisfies Meta<typeof Conversation>;
|
|
9
18
|
export default meta;
|
|
10
19
|
type Story = StoryObj<typeof meta>;
|
|
@@ -24,3 +33,55 @@ export const Default: Story = {
|
|
|
24
33
|
</div>
|
|
25
34
|
),
|
|
26
35
|
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* **Layout lock (#72).** With zero messages, `ConversationEmptyState` must render
|
|
39
|
+
* vertically centred in the conversation canvas — not pinned to the top with a
|
|
40
|
+
* gap of empty space below (the original bug: `ConversationContent` never grew
|
|
41
|
+
* past its shrink-wrapped content size, so the canvas's extra height sat unused
|
|
42
|
+
* below it).
|
|
43
|
+
*
|
|
44
|
+
* This can only be verified with real CSS layout (`getBoundingClientRect`), never
|
|
45
|
+
* a class-string assertion — jsdom performs no layout, and asserting a class name
|
|
46
|
+
* would have passed on the buggy `size-full` markup verbatim (`size-full` was
|
|
47
|
+
* always present; it just had nothing to fill). Per
|
|
48
|
+
* `.claude/rules/component-api.md` § "Regression locks with observable side
|
|
49
|
+
* effects", this runs in the ordinary full-suite invocation, not only isolated.
|
|
50
|
+
*
|
|
51
|
+
* We deliberately measure `ConversationEmptyState`'s OWN rendered box against the
|
|
52
|
+
* outer canvas (`role="log"`'s parent), not against `ConversationContent`'s box —
|
|
53
|
+
* the empty state is `size-full` inside `ConversationContent`, so comparing it to
|
|
54
|
+
* its immediate parent is tautological (it always exactly fills it, bug or not).
|
|
55
|
+
* Comparing against the fixed-height canvas is what actually distinguishes
|
|
56
|
+
* "pinned to the top of a tall canvas" from "filling — and centred within — the
|
|
57
|
+
* canvas".
|
|
58
|
+
*/
|
|
59
|
+
export const EmptyStateCentering: Story = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<div className="flex h-72 flex-col rounded-lg border">
|
|
62
|
+
<Conversation>
|
|
63
|
+
<ConversationContent>
|
|
64
|
+
<ConversationEmptyState
|
|
65
|
+
title="Start the conversation"
|
|
66
|
+
description="Ask anything to begin."
|
|
67
|
+
/>
|
|
68
|
+
</ConversationContent>
|
|
69
|
+
</Conversation>
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
play: async ({ canvasElement }) => {
|
|
73
|
+
const log = within(canvasElement).getByRole("log");
|
|
74
|
+
const canvas = log.parentElement as HTMLElement;
|
|
75
|
+
const heading = within(canvasElement).getByRole("heading", { name: "Start the conversation" });
|
|
76
|
+
// heading (h3) -> the "space-y-1" title/description group -> ConversationEmptyState's own root div.
|
|
77
|
+
const emptyState = heading.parentElement!.parentElement as HTMLElement;
|
|
78
|
+
|
|
79
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
80
|
+
const emptyRect = emptyState.getBoundingClientRect();
|
|
81
|
+
|
|
82
|
+
const gapAbove = emptyRect.top - canvasRect.top;
|
|
83
|
+
const gapBelow = canvasRect.bottom - emptyRect.bottom;
|
|
84
|
+
|
|
85
|
+
await expect(Math.abs(gapAbove - gapBelow)).toBeLessThan(8);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -93,6 +93,19 @@ describe("ConversationEmptyState", () => {
|
|
|
93
93
|
expect(screen.getByText("custom")).toBeInTheDocument();
|
|
94
94
|
expect(screen.queryByRole("heading", { name: "No messages yet" })).toBeNull();
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
// API lock (#72): `actions` completes the EmptyState anatomy (illustration/icon
|
|
98
|
+
// + title + one sentence + one action, per .claude/rules/design-first.md) —
|
|
99
|
+
// without it, ConversationEmptyState could not offer a next step at all.
|
|
100
|
+
it("renders a passed actions node", () => {
|
|
101
|
+
render(<ConversationEmptyState actions={<button type="button">Ask something</button>} />);
|
|
102
|
+
expect(screen.getByRole("button", { name: "Ask something" })).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("omits the actions wrapper entirely when no actions are passed", () => {
|
|
106
|
+
const { container } = render(<ConversationEmptyState />);
|
|
107
|
+
expect(container.querySelectorAll("button")).toHaveLength(0);
|
|
108
|
+
});
|
|
96
109
|
});
|
|
97
110
|
|
|
98
111
|
describe("messagesToMarkdown — the download serializer", () => {
|
package/src/conversation.tsx
CHANGED
|
@@ -22,14 +22,30 @@ export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
|
22
22
|
|
|
23
23
|
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
|
24
24
|
|
|
25
|
-
export const ConversationContent = ({
|
|
26
|
-
|
|
25
|
+
export const ConversationContent = ({
|
|
26
|
+
className,
|
|
27
|
+
scrollClassName,
|
|
28
|
+
...props
|
|
29
|
+
}: ConversationContentProps) => (
|
|
30
|
+
<StickToBottom.Content
|
|
31
|
+
// `use-stick-to-bottom` renders a scroll viewport (`scrollClassName`)
|
|
32
|
+
// wrapping a content div (`className`, via `...props`). The viewport must
|
|
33
|
+
// be a flex column so its height is a DEFINITE main size the content div
|
|
34
|
+
// can consume via `flex-1` — a bare `height:100%` on the content div
|
|
35
|
+
// can't resolve against a block-level parent whose own height is
|
|
36
|
+
// `min-height`-only (the #72 empty-state-pinned-to-the-top bug).
|
|
37
|
+
scrollClassName={cn("flex flex-col", scrollClassName)}
|
|
38
|
+
className={cn("flex flex-1 min-h-0 flex-col gap-8 p-4", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
27
41
|
);
|
|
28
42
|
|
|
29
43
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
|
30
44
|
title?: string;
|
|
31
45
|
description?: string;
|
|
32
46
|
icon?: React.ReactNode;
|
|
47
|
+
/** Primary / secondary action(s) below the description (e.g. suggested prompts). */
|
|
48
|
+
actions?: React.ReactNode;
|
|
33
49
|
};
|
|
34
50
|
|
|
35
51
|
export const ConversationEmptyState = ({
|
|
@@ -37,6 +53,7 @@ export const ConversationEmptyState = ({
|
|
|
37
53
|
title = "No messages yet",
|
|
38
54
|
description = "Start a conversation to see messages here",
|
|
39
55
|
icon,
|
|
56
|
+
actions,
|
|
40
57
|
children,
|
|
41
58
|
...props
|
|
42
59
|
}: ConversationEmptyStateProps) => (
|
|
@@ -54,6 +71,7 @@ export const ConversationEmptyState = ({
|
|
|
54
71
|
<h3 className="font-medium text-sm">{title}</h3>
|
|
55
72
|
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
|
56
73
|
</div>
|
|
74
|
+
{actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
|
|
57
75
|
</>
|
|
58
76
|
)}
|
|
59
77
|
</div>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { ChangeReview, type ChangeHunk, type DiffLine } from "@elabs-ai/components-ui";
|
|
4
|
+
import { DiffView } from "./diff-view";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "AI/DiffView",
|
|
8
|
+
component: DiffView,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: "padded",
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
"The READ-ONLY patch renderer. Pick a diff surface by what the human is allowed " +
|
|
15
|
+
"to do with the change: reading lines of a patch → `AI/DiffView` " +
|
|
16
|
+
"(`@elabs-ai/components-ai`); accepting or rejecting per hunk → " +
|
|
17
|
+
"`AI/ChangeReview` (`@elabs-ai/components-ui`); editing both sides, side by " +
|
|
18
|
+
"side → `Editor/DiffEditor` (`@elabs-ai/components-editor`); reading a " +
|
|
19
|
+
"patch in a console transcript → `Terminal/TerminalDiffHunk` " +
|
|
20
|
+
"(`@elabs-ai/components-terminal`). See " +
|
|
21
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
22
|
+
"This view renders a `DiffLine[]` someone else computed — it never diffs, " +
|
|
23
|
+
"fetches or parses a patch itself. To put those lines behind an approve/reject " +
|
|
24
|
+
"gate, the app that depends on both packages injects one into the other: pass a " +
|
|
25
|
+
"`<DiffView>` through `ChangeReview`’s `renderHunk` render-prop or a " +
|
|
26
|
+
"hunk’s `after` slot, as the *Composed into ChangeReview (ui)* story below " +
|
|
27
|
+
"does. That seam is ratified, not unfinished — `ChangeHunk` gains no " +
|
|
28
|
+
"`lines` field and `DiffLine` never moves, because `@elabs-ai/components-ui` " +
|
|
29
|
+
"may not import `@elabs-ai/components-ai` and this view needs Shiki.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
tags: ["autodocs"],
|
|
34
|
+
} satisfies Meta<typeof DiffView>;
|
|
35
|
+
export default meta;
|
|
36
|
+
type Story = StoryObj<typeof meta>;
|
|
37
|
+
|
|
38
|
+
// A representative mix of every DiffLineType — meta headers, a hunk range, a
|
|
39
|
+
// deletion, two additions and trailing context (#102 acceptance criteria).
|
|
40
|
+
const SAMPLE_LINES: DiffLine[] = [
|
|
41
|
+
{ type: "meta", text: "diff --git a/src/math.ts b/src/math.ts" },
|
|
42
|
+
{ type: "meta", text: "index a1b2c3d..e4f5g6h 100644" },
|
|
43
|
+
{ type: "meta", text: "--- a/src/math.ts" },
|
|
44
|
+
{ type: "meta", text: "+++ b/src/math.ts" },
|
|
45
|
+
{ type: "hunk", text: "@@ -1,5 +1,6 @@" },
|
|
46
|
+
{
|
|
47
|
+
type: "context",
|
|
48
|
+
oldNumber: 1,
|
|
49
|
+
newNumber: 1,
|
|
50
|
+
text: "export function add(a: number, b: number) {",
|
|
51
|
+
},
|
|
52
|
+
{ type: "del", oldNumber: 2, text: " return a - b; // bug: subtracts instead of adds" },
|
|
53
|
+
{ type: "add", newNumber: 2, text: " return a + b;" },
|
|
54
|
+
{ type: "add", newNumber: 3, text: " // fixed the sign error" },
|
|
55
|
+
{ type: "context", oldNumber: 3, newNumber: 4, text: "}" },
|
|
56
|
+
{ type: "context", oldNumber: 4, newNumber: 5, text: "" },
|
|
57
|
+
{ type: "context", oldNumber: 5, newNumber: 6, text: "export const VERSION = 2;" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<div className="max-w-2xl">
|
|
63
|
+
<DiffView
|
|
64
|
+
lines={SAMPLE_LINES}
|
|
65
|
+
file="src/math.ts"
|
|
66
|
+
stats={{ additions: 2, deletions: 1 }}
|
|
67
|
+
language="typescript"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Dark theme — the row tints (`bg-success/10` / `bg-destructive/10`), the
|
|
74
|
+
// marker/gutter tokens and the Shiki-derived syntax colour must all resolve
|
|
75
|
+
// from the SCOPED dark region, not the document root (same pattern as
|
|
76
|
+
// `code-block.stories.tsx`'s `DarkTheme`).
|
|
77
|
+
export const DarkTheme: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<div className="max-w-2xl" data-theme="dark">
|
|
80
|
+
<div className="rounded-lg bg-background p-6 text-foreground">
|
|
81
|
+
<DiffView lines={SAMPLE_LINES} file="src/math.ts" stats={{ additions: 2, deletions: 1 }} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// variant="split" — two aligned columns (old-file view | new-file view).
|
|
88
|
+
export const SplitVariant: Story = {
|
|
89
|
+
name: 'variant="split"',
|
|
90
|
+
render: () => (
|
|
91
|
+
<div className="max-w-3xl">
|
|
92
|
+
<DiffView lines={SAMPLE_LINES} file="src/math.ts" variant="split" />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// pager — absorbs the upstream CodexDiff shape: scroll-position indicator, a
|
|
98
|
+
// key legend, and a named `role="region"`.
|
|
99
|
+
const LONG_LINES: DiffLine[] = Array.from({ length: 60 }, (_, i) => ({
|
|
100
|
+
type: i % 11 === 0 ? "del" : i % 13 === 0 ? "add" : "context",
|
|
101
|
+
oldNumber: i % 13 === 0 ? undefined : i + 1,
|
|
102
|
+
newNumber: i % 11 === 0 ? undefined : i + 1,
|
|
103
|
+
text: `line ${i + 1} of a long file`,
|
|
104
|
+
})) as DiffLine[];
|
|
105
|
+
|
|
106
|
+
export const Pager: Story = {
|
|
107
|
+
name: "pager",
|
|
108
|
+
render: () => (
|
|
109
|
+
<div className="max-w-2xl">
|
|
110
|
+
<DiffView lines={LONG_LINES} file="src/very-long-file.ts" pager />
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// contextLines — a long run of unchanged lines collapses behind a "show more"
|
|
116
|
+
// control that restores it on click.
|
|
117
|
+
const CONTEXT_RUN_LINES: DiffLine[] = [
|
|
118
|
+
{ type: "hunk", text: "@@ -1,12 +1,13 @@" },
|
|
119
|
+
...Array.from({ length: 10 }, (_, i) => ({
|
|
120
|
+
type: "context" as const,
|
|
121
|
+
oldNumber: i + 1,
|
|
122
|
+
newNumber: i + 1,
|
|
123
|
+
text: `unchanged line ${i + 1}`,
|
|
124
|
+
})),
|
|
125
|
+
{ type: "add", newNumber: 11, text: "// a single new line at the end" },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
export const CollapsedContext: Story = {
|
|
129
|
+
name: "contextLines",
|
|
130
|
+
render: () => (
|
|
131
|
+
<div className="max-w-2xl">
|
|
132
|
+
<DiffView lines={CONTEXT_RUN_LINES} contextLines={4} />
|
|
133
|
+
</div>
|
|
134
|
+
),
|
|
135
|
+
play: async ({ canvasElement }) => {
|
|
136
|
+
const canvas = within(canvasElement);
|
|
137
|
+
const showMore = await canvas.findByRole("button", { name: /more line/ });
|
|
138
|
+
await expect(canvas.queryByText("unchanged line 5")).not.toBeInTheDocument();
|
|
139
|
+
await userEvent.click(showMore);
|
|
140
|
+
await expect(canvas.getByText("unchanged line 5")).toBeInTheDocument();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// LOADING — no renderable diff yet (loading-states.md). Layout-shaped
|
|
145
|
+
// skeleton rows at the real row height; no layout shift once `lines` settles.
|
|
146
|
+
export const Loading: Story = {
|
|
147
|
+
render: () => (
|
|
148
|
+
<div className="max-w-2xl">
|
|
149
|
+
<DiffView lines={[]} loading file="src/math.ts" />
|
|
150
|
+
</div>
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// STREAMING — lines are still arriving (loading-states.md `isStreaming`). What
|
|
155
|
+
// exists renders as-is, including a syntactically incomplete final line, and
|
|
156
|
+
// no error surface appears.
|
|
157
|
+
export const Streaming: Story = {
|
|
158
|
+
name: "isStreaming",
|
|
159
|
+
render: () => (
|
|
160
|
+
<div className="max-w-2xl">
|
|
161
|
+
<DiffView
|
|
162
|
+
lines={[
|
|
163
|
+
{ type: "context", oldNumber: 1, newNumber: 1, text: "export function add(a, b) {" },
|
|
164
|
+
{ type: "add", newNumber: 2, text: " return a + b" },
|
|
165
|
+
]}
|
|
166
|
+
isStreaming
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ─── ChangeReview composition (binding decision — architecture § 3) ────────
|
|
173
|
+
//
|
|
174
|
+
// DiffView (ai) and ChangeReview (ui) never import each other. The app, which
|
|
175
|
+
// depends on both, wires them together through ChangeReview's existing
|
|
176
|
+
// injection seams — here, the `renderHunk` render-prop seam. `ChangeHunk`
|
|
177
|
+
// gains no `lines` field; `DiffLine` never moves into `ui`.
|
|
178
|
+
const REVIEW_HUNK_LINES: Record<string, DiffLine[]> = {
|
|
179
|
+
"hunk-1": SAMPLE_LINES.filter((l) => l.type !== "meta"),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const REVIEW_HUNKS: ChangeHunk[] = [{ id: "hunk-1", title: "src/math.ts", status: "modified" }];
|
|
183
|
+
|
|
184
|
+
export const ChangeReviewComposition: Story = {
|
|
185
|
+
name: "Composed into ChangeReview (ui)",
|
|
186
|
+
render: () => (
|
|
187
|
+
<div className="max-w-2xl">
|
|
188
|
+
<ChangeReview
|
|
189
|
+
hunks={REVIEW_HUNKS}
|
|
190
|
+
renderHunk={(hunk) => (
|
|
191
|
+
<DiffView lines={REVIEW_HUNK_LINES[hunk.id] ?? []} variant="inline" />
|
|
192
|
+
)}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
),
|
|
196
|
+
};
|