@elabs-ai/components-ai 4.0.0 → 4.2.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 +62 -12
- 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 +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -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 +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -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 +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- 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/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- 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 +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -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 +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- 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/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- 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/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- 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/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- 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.test.tsx +0 -55
- 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;
|
|
@@ -225,7 +251,7 @@ export const ConfirmationActions = ({ className, ...props }: ConfirmationActions
|
|
|
225
251
|
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
|
226
252
|
|
|
227
253
|
export const ConfirmationAction = ({ className, ...props }: ConfirmationActionProps) => (
|
|
228
|
-
<Button className={cn("h-8 px-3 text-
|
|
254
|
+
<Button className={cn("h-8 px-3 text-body", className)} type="button" {...props} />
|
|
229
255
|
);
|
|
230
256
|
|
|
231
257
|
export type ConfirmationApproveProps = ConfirmationActionProps;
|
|
@@ -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,8 +381,8 @@ 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
|
-
<SheetDescription>
|
|
384
|
+
<SheetTitle>{t("ai.contextPanel.title")}</SheetTitle>
|
|
385
|
+
<SheetDescription>{t("ai.contextPanel.description")}</SheetDescription>
|
|
385
386
|
</SheetHeader>
|
|
386
387
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
387
388
|
</SheetContent>
|
|
@@ -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,78 @@ export const Default: Story = {
|
|
|
24
33
|
</div>
|
|
25
34
|
),
|
|
26
35
|
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `isStreaming` suppresses the live-region chatter while tokens are still
|
|
39
|
+
* arriving (`aria-live="off"` + `aria-busy`) — see the prop's doc on
|
|
40
|
+
* `ConversationProps` for why a `"polite"` region would otherwise announce
|
|
41
|
+
* every incoming token.
|
|
42
|
+
*/
|
|
43
|
+
export const Streaming: Story = {
|
|
44
|
+
render: () => (
|
|
45
|
+
<div className="flex h-72 flex-col rounded-lg border">
|
|
46
|
+
<Conversation isStreaming>
|
|
47
|
+
<ConversationContent>
|
|
48
|
+
<Message from="user">
|
|
49
|
+
<MessageContent>Summarize the deploy log.</MessageContent>
|
|
50
|
+
</Message>
|
|
51
|
+
<Message from="assistant">
|
|
52
|
+
<MessageContent>Deploying to production</MessageContent>
|
|
53
|
+
</Message>
|
|
54
|
+
</ConversationContent>
|
|
55
|
+
</Conversation>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* **Layout lock (#72).** With zero messages, `ConversationEmptyState` must render
|
|
62
|
+
* vertically centred in the conversation canvas — not pinned to the top with a
|
|
63
|
+
* gap of empty space below (the original bug: `ConversationContent` never grew
|
|
64
|
+
* past its shrink-wrapped content size, so the canvas's extra height sat unused
|
|
65
|
+
* below it).
|
|
66
|
+
*
|
|
67
|
+
* This can only be verified with real CSS layout (`getBoundingClientRect`), never
|
|
68
|
+
* a class-string assertion — jsdom performs no layout, and asserting a class name
|
|
69
|
+
* would have passed on the buggy `size-full` markup verbatim (`size-full` was
|
|
70
|
+
* always present; it just had nothing to fill). Per
|
|
71
|
+
* `.claude/rules/component-api.md` § "Regression locks with observable side
|
|
72
|
+
* effects", this runs in the ordinary full-suite invocation, not only isolated.
|
|
73
|
+
*
|
|
74
|
+
* We deliberately measure `ConversationEmptyState`'s OWN rendered box against the
|
|
75
|
+
* outer canvas (`role="log"`'s parent), not against `ConversationContent`'s box —
|
|
76
|
+
* the empty state is `size-full` inside `ConversationContent`, so comparing it to
|
|
77
|
+
* its immediate parent is tautological (it always exactly fills it, bug or not).
|
|
78
|
+
* Comparing against the fixed-height canvas is what actually distinguishes
|
|
79
|
+
* "pinned to the top of a tall canvas" from "filling — and centred within — the
|
|
80
|
+
* canvas".
|
|
81
|
+
*/
|
|
82
|
+
export const EmptyStateCentering: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<div className="flex h-72 flex-col rounded-lg border">
|
|
85
|
+
<Conversation>
|
|
86
|
+
<ConversationContent>
|
|
87
|
+
<ConversationEmptyState
|
|
88
|
+
title="Start the conversation"
|
|
89
|
+
description="Ask anything to begin."
|
|
90
|
+
/>
|
|
91
|
+
</ConversationContent>
|
|
92
|
+
</Conversation>
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
play: async ({ canvasElement }) => {
|
|
96
|
+
const log = within(canvasElement).getByRole("log");
|
|
97
|
+
const canvas = log.parentElement as HTMLElement;
|
|
98
|
+
const heading = within(canvasElement).getByRole("heading", { name: "Start the conversation" });
|
|
99
|
+
// heading (h3) -> the "space-y-1" title/description group -> ConversationEmptyState's own root div.
|
|
100
|
+
const emptyState = heading.parentElement!.parentElement as HTMLElement;
|
|
101
|
+
|
|
102
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
103
|
+
const emptyRect = emptyState.getBoundingClientRect();
|
|
104
|
+
|
|
105
|
+
const gapAbove = emptyRect.top - canvasRect.top;
|
|
106
|
+
const gapBelow = canvasRect.bottom - emptyRect.bottom;
|
|
107
|
+
|
|
108
|
+
await expect(Math.abs(gapAbove - gapBelow)).toBeLessThan(8);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -16,6 +16,7 @@ import type { UIMessage } from "ai";
|
|
|
16
16
|
import {
|
|
17
17
|
Conversation,
|
|
18
18
|
ConversationContent,
|
|
19
|
+
ConversationDownload,
|
|
19
20
|
ConversationEmptyState,
|
|
20
21
|
messagesToMarkdown,
|
|
21
22
|
} from "./conversation";
|
|
@@ -69,6 +70,46 @@ describe("Conversation — transcript region", () => {
|
|
|
69
70
|
expect(screen.getByRole("log")).toHaveClass("extra");
|
|
70
71
|
expect(screen.getByRole("log")).toHaveClass("flex-1");
|
|
71
72
|
});
|
|
73
|
+
|
|
74
|
+
it('defaults to aria-live="polite" and no aria-busy (not streaming)', () => {
|
|
75
|
+
render(
|
|
76
|
+
<Conversation>
|
|
77
|
+
<ConversationContent>x</ConversationContent>
|
|
78
|
+
</Conversation>,
|
|
79
|
+
);
|
|
80
|
+
const log = screen.getByRole("log");
|
|
81
|
+
expect(log).toHaveAttribute("aria-live", "polite");
|
|
82
|
+
expect(log).not.toHaveAttribute("aria-busy");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('suppresses aria-live and sets aria-busy while isStreaming (perf review §2 — role="log" must not announce every token)', () => {
|
|
86
|
+
render(
|
|
87
|
+
<Conversation isStreaming>
|
|
88
|
+
<ConversationContent>x</ConversationContent>
|
|
89
|
+
</Conversation>,
|
|
90
|
+
);
|
|
91
|
+
const log = screen.getByRole("log");
|
|
92
|
+
expect(log).toHaveAttribute("aria-live", "off");
|
|
93
|
+
expect(log).toHaveAttribute("aria-busy", "true");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns to aria-live=polite once streaming settles, so the final content is announced", () => {
|
|
97
|
+
const { rerender } = render(
|
|
98
|
+
<Conversation isStreaming>
|
|
99
|
+
<ConversationContent>partial</ConversationContent>
|
|
100
|
+
</Conversation>,
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByRole("log")).toHaveAttribute("aria-live", "off");
|
|
103
|
+
|
|
104
|
+
rerender(
|
|
105
|
+
<Conversation isStreaming={false}>
|
|
106
|
+
<ConversationContent>final</ConversationContent>
|
|
107
|
+
</Conversation>,
|
|
108
|
+
);
|
|
109
|
+
const log = screen.getByRole("log");
|
|
110
|
+
expect(log).toHaveAttribute("aria-live", "polite");
|
|
111
|
+
expect(log).not.toHaveAttribute("aria-busy");
|
|
112
|
+
});
|
|
72
113
|
});
|
|
73
114
|
|
|
74
115
|
describe("ConversationEmptyState", () => {
|
|
@@ -93,6 +134,31 @@ describe("ConversationEmptyState", () => {
|
|
|
93
134
|
expect(screen.getByText("custom")).toBeInTheDocument();
|
|
94
135
|
expect(screen.queryByRole("heading", { name: "No messages yet" })).toBeNull();
|
|
95
136
|
});
|
|
137
|
+
|
|
138
|
+
// API lock (#72): `actions` completes the EmptyState anatomy (illustration/icon
|
|
139
|
+
// + title + one sentence + one action, per .claude/rules/design-first.md) —
|
|
140
|
+
// without it, ConversationEmptyState could not offer a next step at all.
|
|
141
|
+
it("renders a passed actions node", () => {
|
|
142
|
+
render(<ConversationEmptyState actions={<button type="button">Ask something</button>} />);
|
|
143
|
+
expect(screen.getByRole("button", { name: "Ask something" })).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("omits the actions wrapper entirely when no actions are passed", () => {
|
|
147
|
+
const { container } = render(<ConversationEmptyState />);
|
|
148
|
+
expect(container.querySelectorAll("button")).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("ConversationDownload — accessible name (accessibility.md)", () => {
|
|
153
|
+
it("names the icon-only download control", () => {
|
|
154
|
+
render(<ConversationDownload messages={messages} />);
|
|
155
|
+
expect(screen.getByRole("button", { name: "Download conversation" })).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("lets a caller override the accessible name", () => {
|
|
159
|
+
render(<ConversationDownload aria-label="Export chat" messages={messages} />);
|
|
160
|
+
expect(screen.getByRole("button", { name: "Export chat" })).toBeInTheDocument();
|
|
161
|
+
});
|
|
96
162
|
});
|
|
97
163
|
|
|
98
164
|
describe("messagesToMarkdown — the download serializer", () => {
|
package/src/conversation.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button, downloadBlob } from "@elabs-ai/components-ui";
|
|
3
|
+
import { Button, downloadBlob, useLocale } from "@elabs-ai/components-ui";
|
|
4
4
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
5
|
import type { UIMessage } from "ai";
|
|
6
6
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
|
@@ -8,28 +8,57 @@ import type { ComponentProps } from "react";
|
|
|
8
8
|
import { useCallback } from "react";
|
|
9
9
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
|
10
10
|
|
|
11
|
-
export type ConversationProps = ComponentProps<typeof StickToBottom
|
|
11
|
+
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
|
|
12
|
+
/**
|
|
13
|
+
* The assistant response is still arriving (loading-states.md
|
|
14
|
+
* `isStreaming`). `role="log"` otherwise announces every incoming token to
|
|
15
|
+
* assistive tech as it streams in — deafening. While `true`, `aria-live`
|
|
16
|
+
* is suppressed (`"off"`) and `aria-busy` is set; once streaming ends,
|
|
17
|
+
* `aria-live` returns to `"polite"` so the settled content is what
|
|
18
|
+
* actually gets announced.
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
isStreaming?: boolean;
|
|
22
|
+
};
|
|
12
23
|
|
|
13
|
-
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
24
|
+
export const Conversation = ({ className, isStreaming = false, ...props }: ConversationProps) => (
|
|
14
25
|
<StickToBottom
|
|
15
26
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
|
16
27
|
initial="smooth"
|
|
17
28
|
resize="smooth"
|
|
18
29
|
role="log"
|
|
30
|
+
aria-live={isStreaming ? "off" : "polite"}
|
|
31
|
+
aria-busy={isStreaming || undefined}
|
|
19
32
|
{...props}
|
|
20
33
|
/>
|
|
21
34
|
);
|
|
22
35
|
|
|
23
36
|
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
|
24
37
|
|
|
25
|
-
export const ConversationContent = ({
|
|
26
|
-
|
|
38
|
+
export const ConversationContent = ({
|
|
39
|
+
className,
|
|
40
|
+
scrollClassName,
|
|
41
|
+
...props
|
|
42
|
+
}: ConversationContentProps) => (
|
|
43
|
+
<StickToBottom.Content
|
|
44
|
+
// `use-stick-to-bottom` renders a scroll viewport (`scrollClassName`)
|
|
45
|
+
// wrapping a content div (`className`, via `...props`). The viewport must
|
|
46
|
+
// be a flex column so its height is a DEFINITE main size the content div
|
|
47
|
+
// can consume via `flex-1` — a bare `height:100%` on the content div
|
|
48
|
+
// can't resolve against a block-level parent whose own height is
|
|
49
|
+
// `min-height`-only (the #72 empty-state-pinned-to-the-top bug).
|
|
50
|
+
scrollClassName={cn("flex flex-col", scrollClassName)}
|
|
51
|
+
className={cn("flex flex-1 min-h-0 flex-col gap-8 p-4", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
27
54
|
);
|
|
28
55
|
|
|
29
56
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
|
30
57
|
title?: string;
|
|
31
58
|
description?: string;
|
|
32
59
|
icon?: React.ReactNode;
|
|
60
|
+
/** Primary / secondary action(s) below the description (e.g. suggested prompts). */
|
|
61
|
+
actions?: React.ReactNode;
|
|
33
62
|
};
|
|
34
63
|
|
|
35
64
|
export const ConversationEmptyState = ({
|
|
@@ -37,6 +66,7 @@ export const ConversationEmptyState = ({
|
|
|
37
66
|
title = "No messages yet",
|
|
38
67
|
description = "Start a conversation to see messages here",
|
|
39
68
|
icon,
|
|
69
|
+
actions,
|
|
40
70
|
children,
|
|
41
71
|
...props
|
|
42
72
|
}: ConversationEmptyStateProps) => (
|
|
@@ -51,9 +81,10 @@ export const ConversationEmptyState = ({
|
|
|
51
81
|
<>
|
|
52
82
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
|
53
83
|
<div className="space-y-1">
|
|
54
|
-
<h3 className="font-medium text-
|
|
55
|
-
{description && <p className="text-muted-foreground text-
|
|
84
|
+
<h3 className="font-medium text-body">{title}</h3>
|
|
85
|
+
{description && <p className="text-muted-foreground text-body">{description}</p>}
|
|
56
86
|
</div>
|
|
87
|
+
{actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
|
|
57
88
|
</>
|
|
58
89
|
)}
|
|
59
90
|
</div>
|
|
@@ -66,6 +97,7 @@ export const ConversationScrollButton = ({
|
|
|
66
97
|
...props
|
|
67
98
|
}: ConversationScrollButtonProps) => {
|
|
68
99
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
100
|
+
const { t } = useLocale();
|
|
69
101
|
|
|
70
102
|
const handleScrollToBottom = useCallback(() => {
|
|
71
103
|
scrollToBottom();
|
|
@@ -75,9 +107,16 @@ export const ConversationScrollButton = ({
|
|
|
75
107
|
!isAtBottom && (
|
|
76
108
|
<Button
|
|
77
109
|
className={cn(
|
|
78
|
-
|
|
110
|
+
// Was `dark:bg-background dark:hover:bg-muted` — a hardcoded-dark
|
|
111
|
+
// branch invisible to any other registered theme (styling-and-tokens.md).
|
|
112
|
+
// `bg-background` already matches the `outline` variant's own default;
|
|
113
|
+
// the quieter `hover:bg-muted` (in place of `outline`'s default
|
|
114
|
+
// `hover:bg-accent`) now applies in every theme, not only the two
|
|
115
|
+
// shipped ones.
|
|
116
|
+
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background hover:bg-muted",
|
|
79
117
|
className,
|
|
80
118
|
)}
|
|
119
|
+
aria-label={t("ai.turnStatus.scrollToBottom")}
|
|
81
120
|
onClick={handleScrollToBottom}
|
|
82
121
|
size="icon"
|
|
83
122
|
type="button"
|
|
@@ -120,6 +159,7 @@ export const ConversationDownload = ({
|
|
|
120
159
|
children,
|
|
121
160
|
...props
|
|
122
161
|
}: ConversationDownloadProps) => {
|
|
162
|
+
const { t } = useLocale();
|
|
123
163
|
const handleDownload = useCallback(() => {
|
|
124
164
|
const markdown = messagesToMarkdown(messages, formatMessage);
|
|
125
165
|
downloadBlob(new Blob([markdown], { type: "text/markdown" }), filename);
|
|
@@ -128,9 +168,12 @@ export const ConversationDownload = ({
|
|
|
128
168
|
return (
|
|
129
169
|
<Button
|
|
130
170
|
className={cn(
|
|
131
|
-
|
|
171
|
+
// See `ConversationScrollButton` — was a hardcoded-dark-only branch;
|
|
172
|
+
// `bg-background hover:bg-muted` now applies in every theme.
|
|
173
|
+
"absolute top-4 end-4 rounded-full bg-background hover:bg-muted",
|
|
132
174
|
className,
|
|
133
175
|
)}
|
|
176
|
+
aria-label={t("ai.conversation.download")}
|
|
134
177
|
onClick={handleDownload}
|
|
135
178
|
size="icon"
|
|
136
179
|
type="button"
|