@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn, useLocale } from "@elabs-ai/components-ui";
|
|
4
|
+
import { Cpu, Folder, GitBranch, Loader2Icon, PlugZapIcon } from "lucide-react";
|
|
5
|
+
import type { ComponentProps, ComponentType, ReactNode, SVGProps } from "react";
|
|
6
|
+
import { forwardRef } from "react";
|
|
7
|
+
|
|
8
|
+
export interface SessionStatusBarConnections {
|
|
9
|
+
/** Number of integrations/MCP servers currently connected. */
|
|
10
|
+
connected: number;
|
|
11
|
+
/** Total number of configured integrations/MCP servers. */
|
|
12
|
+
total: number;
|
|
13
|
+
/** Renders a spinner in place of the connection icon while establishing. */
|
|
14
|
+
connecting?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SessionStatusBarProps extends ComponentProps<"div"> {
|
|
18
|
+
/** Current workspace/project name. */
|
|
19
|
+
workspace?: string;
|
|
20
|
+
/** Current git branch. */
|
|
21
|
+
branch?: string;
|
|
22
|
+
/** Active model id/label. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Integration/MCP connection progress; renders a spinner while connecting. */
|
|
25
|
+
connections?: SessionStatusBarConnections;
|
|
26
|
+
/**
|
|
27
|
+
* Docks another component alongside the ambient segments — most often
|
|
28
|
+
* `<TokenUsage />` (`@elabs-ai/components-ai`), so token usage/cost keeps
|
|
29
|
+
* its one owner (`token-usage.tsx`, tokenlens) instead of being re-implemented
|
|
30
|
+
* here. Rendered flush to the end of the bar.
|
|
31
|
+
*/
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SegmentIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
|
36
|
+
|
|
37
|
+
const SessionStatusBarSegment = ({
|
|
38
|
+
icon: Icon,
|
|
39
|
+
slot,
|
|
40
|
+
value,
|
|
41
|
+
}: {
|
|
42
|
+
icon: SegmentIcon;
|
|
43
|
+
slot: string;
|
|
44
|
+
value: string;
|
|
45
|
+
}) => (
|
|
46
|
+
<span className="inline-flex min-w-0 items-center gap-1.5" data-slot={slot}>
|
|
47
|
+
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
48
|
+
<span className="truncate">{value}</span>
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SessionStatusBar — the ambient session row: workspace, branch, model, and
|
|
54
|
+
* integration-connection progress (#105). Every segment is independently
|
|
55
|
+
* optional and renders only when its prop is supplied; with nothing supplied
|
|
56
|
+
* the bar renders nothing at all, not an empty shell.
|
|
57
|
+
*
|
|
58
|
+
* Docks `Context` (or any other node) via `children` rather than
|
|
59
|
+
* re-implementing token-usage/cost maths — `context.tsx` stays the one owner
|
|
60
|
+
* of that calculation (tokenlens).
|
|
61
|
+
*/
|
|
62
|
+
export const SessionStatusBar = forwardRef<HTMLDivElement, SessionStatusBarProps>(
|
|
63
|
+
function SessionStatusBar(
|
|
64
|
+
{ workspace, branch, model, connections, children, className, ...props },
|
|
65
|
+
ref,
|
|
66
|
+
) {
|
|
67
|
+
const { formatNumber, t } = useLocale();
|
|
68
|
+
const hasContent = Boolean(workspace ?? branch ?? model ?? connections ?? children);
|
|
69
|
+
|
|
70
|
+
if (!hasContent) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Computed once and reused for BOTH the connections region's name (for a
|
|
75
|
+
// user who navigates to it deliberately) and its real, non-hidden text
|
|
76
|
+
// content — what a polite announcement actually reads on change. An
|
|
77
|
+
// aria-label alone is NOT that (#155): a live region announces its
|
|
78
|
+
// content, not its own accessible name.
|
|
79
|
+
const connectionsAnnouncement = connections
|
|
80
|
+
? connections.connecting
|
|
81
|
+
? t("ai.sessionStatusBar.connecting")
|
|
82
|
+
: t("ai.sessionStatusBar.connections", {
|
|
83
|
+
connected: connections.connected,
|
|
84
|
+
total: connections.total,
|
|
85
|
+
})
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={ref}
|
|
91
|
+
className={cn(
|
|
92
|
+
"flex w-full items-center gap-4 border-t bg-surface-muted px-3 py-1.5 text-meta text-muted-foreground",
|
|
93
|
+
className,
|
|
94
|
+
)}
|
|
95
|
+
data-slot="session-status-bar"
|
|
96
|
+
{...props}
|
|
97
|
+
>
|
|
98
|
+
{workspace ? (
|
|
99
|
+
<SessionStatusBarSegment
|
|
100
|
+
icon={Folder}
|
|
101
|
+
slot="session-status-bar-workspace"
|
|
102
|
+
value={workspace}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
{branch ? (
|
|
106
|
+
<SessionStatusBarSegment
|
|
107
|
+
icon={GitBranch}
|
|
108
|
+
slot="session-status-bar-branch"
|
|
109
|
+
value={branch}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
{model ? (
|
|
113
|
+
<SessionStatusBarSegment icon={Cpu} slot="session-status-bar-model" value={model} />
|
|
114
|
+
) : null}
|
|
115
|
+
{connections ? (
|
|
116
|
+
<span
|
|
117
|
+
aria-live="polite"
|
|
118
|
+
className="inline-flex shrink-0 items-center gap-1.5"
|
|
119
|
+
data-slot="session-status-bar-connections"
|
|
120
|
+
role="status"
|
|
121
|
+
>
|
|
122
|
+
{connections.connecting ? (
|
|
123
|
+
<Loader2Icon
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
|
|
126
|
+
/>
|
|
127
|
+
) : (
|
|
128
|
+
<PlugZapIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
129
|
+
)}
|
|
130
|
+
<span aria-hidden="true" className="tabular-nums">
|
|
131
|
+
{formatNumber(connections.connected)}/{formatNumber(connections.total)}
|
|
132
|
+
</span>
|
|
133
|
+
{/* The live region's only NON-hidden text — mirrors
|
|
134
|
+
terminal-status-bar.tsx's aria-hidden-visual +
|
|
135
|
+
sr-only-sentence pair (#155). Without this, every polite
|
|
136
|
+
update announced an empty string: the counter above is
|
|
137
|
+
aria-hidden, so it was the region's ONLY text content.
|
|
138
|
+
Deliberately no `aria-label` here (unlike
|
|
139
|
+
terminal-status-bar.tsx, where the label lives on the OUTER
|
|
140
|
+
bar as a generic region name distinct from any segment's
|
|
141
|
+
content): an explicit `aria-label` here would give this
|
|
142
|
+
span an ACCESSIBLE NAME byte-identical to its announced
|
|
143
|
+
CONTENT, which risks a double announcement ("Connecting…
|
|
144
|
+
Connecting…") on ATs that speak a live region's name
|
|
145
|
+
alongside its changed content. Leaving `aria-label` off
|
|
146
|
+
does NOT relocate the name to this text — `role="status"`
|
|
147
|
+
is `nameFrom: author`, so it never falls back to content
|
|
148
|
+
the way e.g. a `button` does. The span simply has no
|
|
149
|
+
accessible name, which is fine: no acceptance criterion
|
|
150
|
+
needs one here, only the announced content does. */}
|
|
151
|
+
<span className="sr-only">{connectionsAnnouncement}</span>
|
|
152
|
+
</span>
|
|
153
|
+
) : null}
|
|
154
|
+
{children ? (
|
|
155
|
+
<div
|
|
156
|
+
className="ms-auto flex shrink-0 items-center"
|
|
157
|
+
data-slot="session-status-bar-context"
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</div>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
);
|
package/src/snippet.stories.tsx
CHANGED
|
@@ -3,7 +3,15 @@ import { Snippet, SnippetAddon, SnippetCopyButton, SnippetInput } from "./snippe
|
|
|
3
3
|
const meta = {
|
|
4
4
|
title: "AI/Snippet",
|
|
5
5
|
component: Snippet,
|
|
6
|
-
parameters: {
|
|
6
|
+
parameters: {
|
|
7
|
+
layout: "padded",
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
"A single-line copyable COMMAND — an `InputGroup` in a mono face wrapping a read-only value and a copy button (`npm i …`, a curl line, an id). Multi-line highlighted source with a language picker is `AI/CodeBlock`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Nothing here highlights: `Snippet` never loads Shiki.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
7
15
|
} satisfies Meta<typeof Snippet>;
|
|
8
16
|
export default meta;
|
|
9
17
|
type Story = StoryObj<typeof meta>;
|
package/src/snippet.test.tsx
CHANGED
|
@@ -88,7 +88,7 @@ describe("SnippetCopyButton", () => {
|
|
|
88
88
|
await waitFor(() => expect(onError).toHaveBeenCalled());
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
it("reports an UNAVAILABLE clipboard through onError (insecure context / old browser)", () => {
|
|
91
|
+
it("reports an UNAVAILABLE clipboard through onError (insecure context / old browser)", async () => {
|
|
92
92
|
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
|
|
93
93
|
const onError = vi.fn();
|
|
94
94
|
render(
|
|
@@ -98,7 +98,11 @@ describe("SnippetCopyButton", () => {
|
|
|
98
98
|
</Snippet>,
|
|
99
99
|
);
|
|
100
100
|
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
|
101
|
-
|
|
101
|
+
// The shared `useCopyToClipboard` hook (`@elabs-ai/components-ui`) always
|
|
102
|
+
// resolves `copy()` on a microtask, even for the synchronous
|
|
103
|
+
// "clipboard missing" case — so this needs a `waitFor`, unlike a
|
|
104
|
+
// hand-rolled synchronous check would have.
|
|
105
|
+
await waitFor(() => expect(onError).toHaveBeenCalled());
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
it("fires onCopy once the write resolves", async () => {
|
package/src/snippet.tsx
CHANGED
|
@@ -8,18 +8,10 @@ import {
|
|
|
8
8
|
InputGroupText,
|
|
9
9
|
} from "@elabs-ai/components-ui";
|
|
10
10
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
11
|
-
import { useLocale } from "@elabs-ai/components-ui";
|
|
11
|
+
import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
|
|
12
12
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
13
13
|
import type { ComponentProps } from "react";
|
|
14
|
-
import {
|
|
15
|
-
createContext,
|
|
16
|
-
useCallback,
|
|
17
|
-
useContext,
|
|
18
|
-
useEffect,
|
|
19
|
-
useMemo,
|
|
20
|
-
useRef,
|
|
21
|
-
useState,
|
|
22
|
-
} from "react";
|
|
14
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
23
15
|
|
|
24
16
|
interface SnippetContextType {
|
|
25
17
|
code: string;
|
|
@@ -79,40 +71,28 @@ export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
|
|
|
79
71
|
export const SnippetCopyButton = ({
|
|
80
72
|
onCopy,
|
|
81
73
|
onError,
|
|
82
|
-
timeout
|
|
74
|
+
timeout,
|
|
83
75
|
children,
|
|
84
76
|
className,
|
|
85
77
|
...props
|
|
86
78
|
}: SnippetCopyButtonProps) => {
|
|
87
79
|
const { t } = useLocale();
|
|
88
|
-
const [isCopied, setIsCopied] = useState(false);
|
|
89
|
-
const timeoutRef = useRef<number>(0);
|
|
90
80
|
const { code } = useContext(SnippetContext);
|
|
81
|
+
// Shared implementation (`@elabs-ai/components-ui`) instead of a private
|
|
82
|
+
// copy of the same copy-to-clipboard state machine (issue-workflow.md
|
|
83
|
+
// dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
|
|
84
|
+
const { copied: isCopied, copy } = useCopyToClipboard(
|
|
85
|
+
timeout === undefined ? undefined : { resetAfterMs: timeout },
|
|
86
|
+
);
|
|
91
87
|
|
|
92
88
|
const copyToClipboard = useCallback(async () => {
|
|
93
|
-
|
|
89
|
+
const ok = await copy(code);
|
|
90
|
+
if (ok) {
|
|
91
|
+
onCopy?.();
|
|
92
|
+
} else {
|
|
94
93
|
onError?.(new Error("Clipboard API not available"));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
if (!isCopied) {
|
|
100
|
-
await navigator.clipboard.writeText(code);
|
|
101
|
-
setIsCopied(true);
|
|
102
|
-
onCopy?.();
|
|
103
|
-
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
|
104
|
-
}
|
|
105
|
-
} catch (error) {
|
|
106
|
-
onError?.(error as Error);
|
|
107
94
|
}
|
|
108
|
-
}, [code, onCopy, onError
|
|
109
|
-
|
|
110
|
-
useEffect(
|
|
111
|
-
() => () => {
|
|
112
|
-
window.clearTimeout(timeoutRef.current);
|
|
113
|
-
},
|
|
114
|
-
[],
|
|
115
|
-
);
|
|
95
|
+
}, [copy, code, onCopy, onError]);
|
|
116
96
|
|
|
117
97
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
118
98
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speech-input.test.tsx — the MediaRecorder fallback path's two race bugs:
|
|
3
|
+
* a double click starting two recorders before the first `getUserMedia()`
|
|
4
|
+
* resolves, and an unmount during a pending `getUserMedia()` leaving the
|
|
5
|
+
* microphone on with nothing left to stop it.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
9
|
+
import { SpeechInput } from "./speech-input";
|
|
10
|
+
|
|
11
|
+
class FakeMediaRecorder {
|
|
12
|
+
state: "inactive" | "recording" = "inactive";
|
|
13
|
+
private listeners = new Map<string, Set<(event: Event) => void>>();
|
|
14
|
+
|
|
15
|
+
constructor(public stream: MediaStream) {}
|
|
16
|
+
|
|
17
|
+
addEventListener(type: string, cb: (event: Event) => void) {
|
|
18
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
19
|
+
this.listeners.get(type)?.add(cb);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
removeEventListener(type: string, cb: (event: Event) => void) {
|
|
23
|
+
this.listeners.get(type)?.delete(cb);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start() {
|
|
27
|
+
this.state = "recording";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stop() {
|
|
31
|
+
this.state = "inactive";
|
|
32
|
+
for (const cb of this.listeners.get("stop") ?? []) cb(new Event("stop"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeFakeStream() {
|
|
37
|
+
const tracks = [{ stop: vi.fn() }, { stop: vi.fn() }];
|
|
38
|
+
return {
|
|
39
|
+
getTracks: () => tracks,
|
|
40
|
+
tracks,
|
|
41
|
+
} as unknown as MediaStream & { tracks: { stop: ReturnType<typeof vi.fn> }[] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stubMediaRecorderEnvironment(getUserMedia: (...args: unknown[]) => unknown) {
|
|
45
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
46
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
47
|
+
configurable: true,
|
|
48
|
+
value: { getUserMedia },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
cleanup();
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
// @ts-expect-error -- test-only cleanup of a property we defined above
|
|
56
|
+
delete navigator.mediaDevices;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("SpeechInput — media-recorder fallback getUserMedia race guards", () => {
|
|
60
|
+
it("does not call getUserMedia twice for a double click before the first call resolves", async () => {
|
|
61
|
+
let resolveGetUserMedia: ((stream: MediaStream) => void) | undefined;
|
|
62
|
+
const getUserMedia = vi.fn(
|
|
63
|
+
() =>
|
|
64
|
+
new Promise((resolve: (stream: MediaStream) => void) => {
|
|
65
|
+
resolveGetUserMedia = resolve;
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
stubMediaRecorderEnvironment(getUserMedia);
|
|
69
|
+
|
|
70
|
+
const startSpy = vi.spyOn(FakeMediaRecorder.prototype, "start");
|
|
71
|
+
const { getByRole } = render(<SpeechInput onAudioRecorded={() => Promise.resolve("hi")} />);
|
|
72
|
+
const button = getByRole("button");
|
|
73
|
+
|
|
74
|
+
fireEvent.click(button);
|
|
75
|
+
fireEvent.click(button);
|
|
76
|
+
|
|
77
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
78
|
+
|
|
79
|
+
resolveGetUserMedia?.(makeFakeStream());
|
|
80
|
+
// Only ONE recorder is ever created/started — the second click's
|
|
81
|
+
// `getUserMedia()` call was suppressed by the in-flight guard, not merely
|
|
82
|
+
// delayed.
|
|
83
|
+
await waitFor(() => expect(startSpy).toHaveBeenCalledTimes(1));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("stops the granted tracks when the component unmounts before getUserMedia resolves", async () => {
|
|
87
|
+
let resolveGetUserMedia: ((stream: MediaStream) => void) | undefined;
|
|
88
|
+
const getUserMedia = vi.fn(
|
|
89
|
+
() =>
|
|
90
|
+
new Promise((resolve: (stream: MediaStream) => void) => {
|
|
91
|
+
resolveGetUserMedia = resolve;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
stubMediaRecorderEnvironment(getUserMedia);
|
|
95
|
+
|
|
96
|
+
const { getByRole, unmount } = render(
|
|
97
|
+
<SpeechInput onAudioRecorded={() => Promise.resolve("hi")} />,
|
|
98
|
+
);
|
|
99
|
+
fireEvent.click(getByRole("button"));
|
|
100
|
+
|
|
101
|
+
unmount();
|
|
102
|
+
|
|
103
|
+
const stream = makeFakeStream();
|
|
104
|
+
resolveGetUserMedia?.(stream);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => expect(stream.tracks[0]?.stop).toHaveBeenCalledTimes(1));
|
|
107
|
+
expect(stream.tracks[1]?.stop).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/speech-input.tsx
CHANGED
|
@@ -101,6 +101,21 @@ export const SpeechInput = ({
|
|
|
101
101
|
const onTranscriptionChangeRef =
|
|
102
102
|
useRef<SpeechInputProps["onTranscriptionChange"]>(onTranscriptionChange);
|
|
103
103
|
const onAudioRecordedRef = useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
|
|
104
|
+
// Guards a pending `getUserMedia()` call against a second click/toggle
|
|
105
|
+
// firing before the first resolves (both see `isListening === false`, since
|
|
106
|
+
// that flips only once the stream is actually granted).
|
|
107
|
+
const isStartingMediaRecorderRef = useRef(false);
|
|
108
|
+
// Flips false on unmount so an in-flight `getUserMedia()` that resolves
|
|
109
|
+
// AFTER teardown stops its just-granted tracks immediately instead of
|
|
110
|
+
// leaving the microphone on with nothing listening.
|
|
111
|
+
const isMountedRef = useRef(true);
|
|
112
|
+
|
|
113
|
+
useEffect(
|
|
114
|
+
() => () => {
|
|
115
|
+
isMountedRef.current = false;
|
|
116
|
+
},
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
104
119
|
|
|
105
120
|
// Keep refs in sync
|
|
106
121
|
onTranscriptionChangeRef.current = onTranscriptionChange;
|
|
@@ -183,12 +198,23 @@ export const SpeechInput = ({
|
|
|
183
198
|
|
|
184
199
|
// Start MediaRecorder recording
|
|
185
200
|
const startMediaRecorder = useCallback(async () => {
|
|
186
|
-
if (!onAudioRecordedRef.current) {
|
|
201
|
+
if (!onAudioRecordedRef.current || isStartingMediaRecorderRef.current) {
|
|
187
202
|
return;
|
|
188
203
|
}
|
|
189
204
|
|
|
205
|
+
isStartingMediaRecorderRef.current = true;
|
|
190
206
|
try {
|
|
191
207
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
208
|
+
|
|
209
|
+
// Unmounted (or stopped) while getUserMedia was pending — the granted
|
|
210
|
+
// tracks have no listener left to stop them later, so stop them now.
|
|
211
|
+
if (!isMountedRef.current) {
|
|
212
|
+
for (const track of stream.getTracks()) {
|
|
213
|
+
track.stop();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
streamRef.current = stream;
|
|
193
219
|
const mediaRecorder = new MediaRecorder(stream);
|
|
194
220
|
audioChunksRef.current = [];
|
|
@@ -241,6 +267,8 @@ export const SpeechInput = ({
|
|
|
241
267
|
setIsListening(true);
|
|
242
268
|
} catch {
|
|
243
269
|
setIsListening(false);
|
|
270
|
+
} finally {
|
|
271
|
+
isStartingMediaRecorderRef.current = false;
|
|
244
272
|
}
|
|
245
273
|
}, []);
|
|
246
274
|
|
|
@@ -281,7 +309,7 @@ export const SpeechInput = ({
|
|
|
281
309
|
{isListening &&
|
|
282
310
|
[0, 1, 2].map((index) => (
|
|
283
311
|
<div
|
|
284
|
-
className="absolute inset-0 animate-ping rounded-full border-2 border-
|
|
312
|
+
className="absolute inset-0 animate-ping rounded-full border-2 border-destructive/30"
|
|
285
313
|
key={index}
|
|
286
314
|
style={{
|
|
287
315
|
animationDelay: `${index * 0.3}s`,
|
|
@@ -295,7 +323,7 @@ export const SpeechInput = ({
|
|
|
295
323
|
className={cn(
|
|
296
324
|
"relative z-10 rounded-full transition-colors duration-slow ease-standard motion-reduce:transition-none",
|
|
297
325
|
isListening
|
|
298
|
-
? "bg-destructive text-
|
|
326
|
+
? "bg-destructive text-destructive-foreground hover:bg-destructive/80 hover:text-destructive-foreground"
|
|
299
327
|
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
|
|
300
328
|
className,
|
|
301
329
|
)}
|
package/src/stack-trace.tsx
CHANGED
|
@@ -3,19 +3,11 @@
|
|
|
3
3
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
4
4
|
import { Button } from "@elabs-ai/components-ui";
|
|
5
5
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
6
|
+
import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
|
|
6
7
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
8
|
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
|
|
8
9
|
import type { ComponentProps } from "react";
|
|
9
|
-
import {
|
|
10
|
-
createContext,
|
|
11
|
-
memo,
|
|
12
|
-
useCallback,
|
|
13
|
-
useContext,
|
|
14
|
-
useEffect,
|
|
15
|
-
useMemo,
|
|
16
|
-
useRef,
|
|
17
|
-
useState,
|
|
18
|
-
} from "react";
|
|
10
|
+
import { createContext, memo, useCallback, useContext, useMemo } from "react";
|
|
19
11
|
|
|
20
12
|
// Regex patterns for parsing stack traces
|
|
21
13
|
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
|
@@ -187,7 +179,7 @@ export const StackTrace = memo(
|
|
|
187
179
|
<StackTraceContext.Provider value={contextValue}>
|
|
188
180
|
<div
|
|
189
181
|
className={cn(
|
|
190
|
-
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-
|
|
182
|
+
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-body",
|
|
191
183
|
className,
|
|
192
184
|
)}
|
|
193
185
|
{...props}
|
|
@@ -236,7 +228,9 @@ export const StackTraceErrorType = memo(
|
|
|
236
228
|
const { trace } = useStackTrace();
|
|
237
229
|
|
|
238
230
|
return (
|
|
239
|
-
|
|
231
|
+
// #124: the error type NAME is running text (e.g. "TypeError"), not a
|
|
232
|
+
// mark — ink rung, unlike the AlertTriangleIcon mark above.
|
|
233
|
+
<span className={cn("shrink-0 font-semibold text-destructive-text", className)} {...props}>
|
|
240
234
|
{children ?? trace.errorType}
|
|
241
235
|
</span>
|
|
242
236
|
);
|
|
@@ -290,42 +284,35 @@ export const StackTraceCopyButton = memo(
|
|
|
290
284
|
({
|
|
291
285
|
onCopy,
|
|
292
286
|
onError,
|
|
293
|
-
timeout
|
|
287
|
+
timeout,
|
|
294
288
|
className,
|
|
295
289
|
children,
|
|
290
|
+
"aria-label": ariaLabel,
|
|
296
291
|
...props
|
|
297
292
|
}: StackTraceCopyButtonProps) => {
|
|
298
|
-
const
|
|
299
|
-
const timeoutRef = useRef<number>(0);
|
|
293
|
+
const { t } = useLocale();
|
|
300
294
|
const { raw } = useStackTrace();
|
|
295
|
+
// Shared implementation (`@elabs-ai/components-ui`) instead of a private
|
|
296
|
+
// copy of the same copy-to-clipboard state machine (issue-workflow.md
|
|
297
|
+
// dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
|
|
298
|
+
const { copied: isCopied, copy } = useCopyToClipboard(
|
|
299
|
+
timeout === undefined ? undefined : { resetAfterMs: timeout },
|
|
300
|
+
);
|
|
301
301
|
|
|
302
302
|
const copyToClipboard = useCallback(async () => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
await navigator.clipboard.writeText(raw);
|
|
310
|
-
setIsCopied(true);
|
|
303
|
+
const ok = await copy(raw);
|
|
304
|
+
if (ok) {
|
|
311
305
|
onCopy?.();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
onError?.(error as Error);
|
|
306
|
+
} else {
|
|
307
|
+
onError?.(new Error("Clipboard API not available"));
|
|
315
308
|
}
|
|
316
|
-
}, [raw, onCopy, onError
|
|
317
|
-
|
|
318
|
-
useEffect(
|
|
319
|
-
() => () => {
|
|
320
|
-
window.clearTimeout(timeoutRef.current);
|
|
321
|
-
},
|
|
322
|
-
[],
|
|
323
|
-
);
|
|
309
|
+
}, [copy, raw, onCopy, onError]);
|
|
324
310
|
|
|
325
311
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
326
312
|
|
|
327
313
|
return (
|
|
328
314
|
<Button
|
|
315
|
+
aria-label={ariaLabel ?? t("copy")}
|
|
329
316
|
className={cn("size-7", className)}
|
|
330
317
|
onClick={copyToClipboard}
|
|
331
318
|
size="icon"
|
|
@@ -426,6 +413,7 @@ FilePathButton.displayName = "FilePathButton";
|
|
|
426
413
|
export const StackTraceFrames = memo(
|
|
427
414
|
({ className, showInternalFrames = true, ...props }: StackTraceFramesProps) => {
|
|
428
415
|
const { trace, onFilePathClick } = useStackTrace();
|
|
416
|
+
const { t } = useLocale();
|
|
429
417
|
|
|
430
418
|
const framesToShow = showInternalFrames
|
|
431
419
|
? trace.frames
|
|
@@ -436,7 +424,7 @@ export const StackTraceFrames = memo(
|
|
|
436
424
|
{framesToShow.map((frame) => (
|
|
437
425
|
<div
|
|
438
426
|
className={cn(
|
|
439
|
-
"text-
|
|
427
|
+
"text-meta",
|
|
440
428
|
frame.isInternal ? "text-muted-foreground/50" : "text-foreground/90",
|
|
441
429
|
)}
|
|
442
430
|
key={frame.raw}
|
|
@@ -460,7 +448,7 @@ export const StackTraceFrames = memo(
|
|
|
460
448
|
</div>
|
|
461
449
|
))}
|
|
462
450
|
{framesToShow.length === 0 && (
|
|
463
|
-
<div className="text-muted-foreground text-
|
|
451
|
+
<div className="text-muted-foreground text-meta">{t("ai.stackTrace.empty")}</div>
|
|
464
452
|
)}
|
|
465
453
|
</div>
|
|
466
454
|
);
|
|
@@ -13,6 +13,7 @@ import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
|
13
13
|
import { MessageResponse } from "./message";
|
|
14
14
|
import { MarkdownView } from "./markdown-view";
|
|
15
15
|
import { Reasoning, ReasoningContent } from "./reasoning";
|
|
16
|
+
import { _debugActiveThemeScopeCount } from "./_theme-scope-store";
|
|
16
17
|
|
|
17
18
|
const CODE_MARKDOWN = "```js\nconst a = 1;\n```";
|
|
18
19
|
// `MarkdownView` re-maps `code` onto `ProseInlineCode`, so its Streamdown chrome
|
|
@@ -107,6 +108,28 @@ describe("Streamdown chrome microcopy (#310)", () => {
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
describe("useReactiveCodePlugin shares ONE theme-scope observer (perf review §3.3)", () => {
|
|
112
|
+
it("registers a single tracked scope for many simultaneously-mounted MessageResponse instances", () => {
|
|
113
|
+
const before = _debugActiveThemeScopeCount();
|
|
114
|
+
|
|
115
|
+
const { unmount } = render(
|
|
116
|
+
<>
|
|
117
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
118
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
119
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
120
|
+
</>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Under the old per-hook `MutationObserver`, a message-dense conversation
|
|
124
|
+
// registered one observer PER instance; the shared store collapses every
|
|
125
|
+
// instance watching the same `<html>` scope into exactly one.
|
|
126
|
+
expect(_debugActiveThemeScopeCount()).toBe(before + 1);
|
|
127
|
+
|
|
128
|
+
unmount();
|
|
129
|
+
expect(_debugActiveThemeScopeCount()).toBe(before);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
110
133
|
/**
|
|
111
134
|
* #315 follow-up — the "major" carve-out: only `<CodeBlock>` had been
|
|
112
135
|
* de-GitHub-ed; every fenced code block rendered through `MessageResponse`/
|
|
@@ -129,7 +152,7 @@ describe("Streamdown code-block theme (#315 follow-up)", () => {
|
|
|
129
152
|
// (github-light's keyword is #d73a49, github-dark's is #ff7b72).
|
|
130
153
|
expect(span.getAttribute("style")).toContain("--sdm-c: #133796");
|
|
131
154
|
// Both dual-theme slots are pinned to the SAME active-theme color (#315
|
|
132
|
-
// follow-up design: brand-ui has
|
|
155
|
+
// follow-up design: brand-ui has more themes than the two Shiki's
|
|
133
156
|
// light/dark mechanism expects), so `.dark` never resolves a different palette.
|
|
134
157
|
expect(span.getAttribute("style")).toContain("--shiki-dark: #133796");
|
|
135
158
|
});
|
package/src/task.stories.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { Task, TaskContent, TaskItem, TaskItemFile, TaskTrigger } from "./task";
|
|
3
|
-
const meta = {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const meta = {
|
|
4
|
+
title: "AI/Task",
|
|
5
|
+
component: Task,
|
|
6
|
+
parameters: {
|
|
7
|
+
layout: "padded",
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
"The CHAT run summary — what got done, collapsed by default so a settled transcript keeps only its focal surfaces open. The console counterpart of an agent checklist is `Terminal/TerminalTodoList`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). The body rides the canonical `AI/AgentTimeline` rail rather than a hand-rolled one, and what the agent intends to do BEFORE it runs is `AI/Plan`.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<typeof Task>;
|
|
6
16
|
export default meta;
|
|
7
17
|
type Story = StoryObj<typeof meta>;
|
|
8
18
|
// Collapsed by default since #192; `defaultOpen` here only so the docs render
|