@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.
Files changed (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -1,11 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { Alert, AlertDescription, AlertTitle, StatusBadge } from "@elabs-ai/components-ui";
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 contextValue = useMemo(() => ({ approval, state, titleId }), [approval, state, titleId]);
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: { layout: "padded" },
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>;
@@ -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>Context panel</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 { Conversation, ConversationContent } from "./conversation";
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: { layout: "padded" },
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", () => {
@@ -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 = ({ className, ...props }: ConversationContentProps) => (
26
- <StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
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
+ };