React improvements

This commit is contained in:
Hayden Bleasel
2026-02-05 10:59:02 -08:00
parent e7566cacc8
commit 8bdd7fecb9
27 changed files with 569 additions and 394 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"ai-elements": patch
---
React improvements
+11 -1
View File
@@ -20,5 +20,15 @@
"newlinesBetween": true,
"order": "asc",
},
"ignorePatterns": ["packages/shadcn-ui"],
"ignorePatterns": [
"packages/shadcn-ui",
"apps/docs/components/geistdocs",
"apps/docs/lib/geistdocs",
"apps/docs/hooks/geistdocs",
"apps/docs/app",
"apps/docs/geistdocs.tsx",
"apps/docs/proxy.ts",
"apps/docs/source.config.ts",
"skills",
],
}
@@ -321,11 +321,8 @@ describe("speechInput - Speech Recognition", () => {
expect(handleTranscription).not.toHaveBeenCalled();
});
it("handles speech recognition errors and logs them", async () => {
it("handles speech recognition errors and stops listening", async () => {
setupSpeechInputTests();
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(vi.fn());
const instanceRef: InstanceRef = { current: null };
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
@@ -350,14 +347,10 @@ describe("speechInput - Speech Recognition", () => {
});
instanceRef.current?.dispatchEvent(errorEvent);
// Button should return to mic icon (not listening state)
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Speech recognition error:",
"no-speech"
);
expect(screen.getByRole("button")).not.toBeDisabled();
});
consoleErrorSpy.mockRestore();
});
it("handles empty transcript gracefully", async () => {
@@ -677,9 +670,6 @@ describe("speechInput - MediaRecorder Fallback", () => {
it("handles transcription errors gracefully", async () => {
const ctx = setupMediaRecorderTests();
const user = userEvent.setup();
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(vi.fn());
const handleAudioRecorded = vi
.fn()
.mockRejectedValue(new Error("Transcription failed"));
@@ -716,16 +706,12 @@ describe("speechInput - MediaRecorder Fallback", () => {
// Stop recording
await user.click(button);
// Wait for the error to be handled and processing to complete
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Transcription error:",
expect.any(Error)
);
expect(handleAudioRecorded).toHaveBeenCalled();
});
// Transcription change should not be called on error
expect(handleTranscriptionChange).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
+10 -10
View File
@@ -39,6 +39,15 @@ export type AttachmentMediaCategory =
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
@@ -247,16 +256,7 @@ export const AttachmentPreview = ({
return <video className="size-full object-cover" muted src={data.url} />;
}
const iconMap: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
const Icon = iconMap[mediaCategory];
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
+3 -1
View File
@@ -8,9 +8,11 @@ type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};
const deleteKeyCode = ["Backspace", "Delete"];
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={["Backspace", "Delete"]}
deleteKeyCode={deleteKeyCode}
fitView
panOnDrag={false}
panOnScroll
+28 -30
View File
@@ -109,6 +109,12 @@ export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
status?: "complete" | "active" | "pending";
};
const stepStatusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
export const ChainOfThoughtStep = memo(
({
className,
@@ -118,37 +124,29 @@ export const ChainOfThoughtStep = memo(
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => {
const statusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
return (
<div
className={cn(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
}: ChainOfThoughtStepProps) => (
<div
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
);
}
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
)
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
+27 -13
View File
@@ -394,11 +394,21 @@ export const CodeBlockContent = ({
);
useEffect(() => {
let cancelled = false;
// Reset to raw tokens when code changes (shows current code, not stale tokens)
setTokenized(highlightCode(code, language) ?? rawTokens);
// Subscribe to async highlighting result
highlightCode(code, language, setTokenized);
highlightCode(code, language, (result) => {
if (!cancelled) {
setTokenized(result);
}
});
return () => {
cancelled = true;
};
}, [code, language, rawTokens]);
return (
@@ -415,18 +425,22 @@ export const CodeBlock = ({
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
+5 -3
View File
@@ -147,15 +147,17 @@ export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
date: Date;
};
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
});
export const CommitTimestamp = ({
date,
className,
children,
...props
}: CommitTimestampProps) => {
const formatted = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
}).format(
const formatted = relativeTimeFormat.format(
Math.round((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
"day"
);
+13 -13
View File
@@ -11,7 +11,7 @@ import {
} from "@repo/shadcn-ui/components/ui/hover-card";
import { Progress } from "@repo/shadcn-ui/components/ui/progress";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo } from "react";
import { getUsage } from "tokenlens";
const PERCENT_MAX = 100;
@@ -49,18 +49,18 @@ export const Context = ({
usage,
modelId,
...props
}: ContextProps) => (
<ContextContext.Provider
value={{
maxTokens,
modelId,
usage,
usedTokens,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
}: ContextProps) => {
const contextValue = useMemo(
() => ({ maxTokens, modelId, usage, usedTokens }),
[maxTokens, modelId, usage, usedTokens]
);
return (
<ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
};
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
+55 -27
View File
@@ -7,7 +7,15 @@ import { Button } from "@repo/shadcn-ui/components/ui/button";
import { Switch } from "@repo/shadcn-ui/components/ui/switch";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { createContext, useCallback, useContext, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface EnvironmentVariablesContextType {
showValues: boolean;
@@ -42,13 +50,21 @@ export const EnvironmentVariables = ({
useState(defaultShowValues);
const showValues = controlledShowValues ?? internalShowValues;
const setShowValues = (show: boolean) => {
setInternalShowValues(show);
onShowValuesChange?.(show);
};
const setShowValues = useCallback(
(show: boolean) => {
setInternalShowValues(show);
onShowValuesChange?.(show);
},
[onShowValuesChange]
);
const contextValue = useMemo(
() => ({ setShowValues, showValues }),
[setShowValues, showValues]
);
return (
<EnvironmentVariablesContext.Provider value={{ setShowValues, showValues }}>
<EnvironmentVariablesContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
@@ -146,26 +162,30 @@ export const EnvironmentVariable = ({
className,
children,
...props
}: EnvironmentVariableProps) => (
<EnvironmentVariableContext.Provider value={{ name, value }}>
<div
className={cn(
"flex items-center justify-between gap-4 px-4 py-3",
className
)}
{...props}
>
{children ?? (
<>
<div className="flex items-center gap-2">
<EnvironmentVariableName />
</div>
<EnvironmentVariableValue />
</>
)}
</div>
</EnvironmentVariableContext.Provider>
);
}: EnvironmentVariableProps) => {
const envVarContextValue = useMemo(() => ({ name, value }), [name, value]);
return (
<EnvironmentVariableContext.Provider value={envVarContextValue}>
<div
className={cn(
"flex items-center justify-between gap-4 px-4 py-3",
className
)}
{...props}
>
{children ?? (
<>
<div className="flex items-center gap-2">
<EnvironmentVariableName />
</div>
<EnvironmentVariableValue />
</>
)}
</div>
</EnvironmentVariableContext.Provider>
);
};
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>;
@@ -242,6 +262,7 @@ export const EnvironmentVariableCopyButton = ({
...props
}: EnvironmentVariableCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { name, value } = useContext(EnvironmentVariableContext);
const getTextToCopy = useCallback((): string => {
@@ -263,12 +284,19 @@ export const EnvironmentVariableCopyButton = ({
await navigator.clipboard.writeText(getTextToCopy());
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [getTextToCopy, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
+35 -16
View File
@@ -14,7 +14,13 @@ import {
FolderIcon,
FolderOpenIcon,
} from "lucide-react";
import { createContext, useCallback, useContext, useState } from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
interface FileTreeContextType {
expandedPaths: Set<string>;
@@ -54,21 +60,27 @@ export const FileTree = ({
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const expandedPaths = controlledExpanded ?? internalExpanded;
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setInternalExpanded(newExpanded);
onExpandedChange?.(newExpanded);
};
const togglePath = useCallback(
(path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setInternalExpanded(newExpanded);
onExpandedChange?.(newExpanded);
},
[expandedPaths, onExpandedChange]
);
const contextValue = useMemo(
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
[expandedPaths, onSelect, selectedPath, togglePath]
);
return (
<FileTreeContext.Provider
value={{ expandedPaths, onSelect, selectedPath, togglePath }}
>
<FileTreeContext.Provider value={contextValue}>
<div
className={cn(
"rounded-lg border bg-background font-mono text-sm",
@@ -120,8 +132,13 @@ export const FileTreeFolder = ({
onSelect?.(path);
}, [onSelect, path]);
const folderContextValue = useMemo(
() => ({ isExpanded, name, path }),
[isExpanded, name, path]
);
return (
<FileTreeFolderContext.Provider value={{ isExpanded, name, path }}>
<FileTreeFolderContext.Provider value={folderContextValue}>
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
<div
className={cn("", className)}
@@ -203,8 +220,10 @@ export const FileTreeFile = ({
[onSelect, path]
);
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
return (
<FileTreeFileContext.Provider value={{ name, path }}>
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
+8 -2
View File
@@ -167,9 +167,15 @@ export const InlineCitationCarouselIndex = ({
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
const handleSelect = () => {
setCurrent(api.selectedScrollSnap() + 1);
});
};
api.on("select", handleSelect);
return () => {
api.off("select", handleSelect);
};
}, [api]);
return (
+7 -6
View File
@@ -125,19 +125,20 @@ export const JSXPreview = memo(
children,
...props
}: JSXPreviewProps) => {
const [prevJsx, setPrevJsx] = useState(jsx);
const [error, setError] = useState<Error | null>(null);
// Clear error when jsx changes (derived state pattern)
if (jsx !== prevJsx) {
setPrevJsx(jsx);
setError(null);
}
const processedJsx = useMemo(
() => (isStreaming ? completeJsxTag(jsx) : jsx),
[jsx, isStreaming]
);
// Clear error when jsx changes
// biome-ignore lint/correctness/useExhaustiveDependencies: jsx change should reset error
useEffect(() => {
setError(null);
}, [jsx]);
return (
<JSXPreviewContext.Provider
value={{
+26 -17
View File
@@ -23,6 +23,7 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
@@ -152,31 +153,37 @@ export const MessageBranch = ({
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = () => {
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = () => {
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue: MessageBranchContextType = {
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
};
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
@@ -315,6 +322,8 @@ export const MessageBranchPage = ({
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
@@ -322,7 +331,7 @@ export const MessageResponse = memo(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={{ cjk, code, math, mermaid }}
plugins={streamdownPlugins}
{...props}
/>
),
+15 -11
View File
@@ -23,6 +23,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -85,18 +86,21 @@ export const MicSelector = ({
}
}, [open, hasPermission, loading, loadDevices]);
const contextValue = useMemo(
() => ({
data: devices,
onOpenChange,
onValueChange,
open,
setWidth,
value,
width,
}),
[devices, onOpenChange, onValueChange, open, setWidth, value, width]
);
return (
<MicSelectorContext.Provider
value={{
data: devices,
onOpenChange,
onValueChange,
open,
setWidth,
value,
width,
}}
>
<MicSelectorContext.Provider value={contextValue}>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
</MicSelectorContext.Provider>
);
+11 -8
View File
@@ -198,14 +198,17 @@ export const Persona: FC<PersonaProps> = memo(
onReady,
onStop,
});
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
};
useEffect(() => {
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
};
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
const stableCallbacks = useMemo(
() => ({
+8 -2
View File
@@ -225,7 +225,10 @@ export const PromptInputProvider = ({
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = attachmentFiles;
useEffect(() => {
attachmentsRef.current = attachmentFiles;
}, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(
@@ -417,7 +420,10 @@ export const PromptInput = ({
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files);
filesRef.current = files;
useEffect(() => {
filesRef.current = files;
}, [files]);
const openFileDialogLocal = useCallback(() => {
inputRef.current?.click();
+28 -23
View File
@@ -20,6 +20,8 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
@@ -79,28 +81,22 @@ export const Reasoning = memo(
prop: durationProp,
});
const [hasEverStreamed, setHasEverStreamed] = useState(isStreaming);
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts
useEffect(() => {
if (isStreaming && !hasEverStreamed) {
setHasEverStreamed(true);
}
}, [isStreaming, hasEverStreamed]);
// Track duration when streaming starts and ends
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, startTime, setDuration]);
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
@@ -111,8 +107,12 @@ export const Reasoning = memo(
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (hasEverStreamed && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
@@ -120,7 +120,7 @@ export const Reasoning = memo(
return () => clearTimeout(timer);
}
}, [hasEverStreamed, isStreaming, isOpen, setIsOpen, hasAutoClosed]);
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
@@ -129,10 +129,13 @@ export const Reasoning = memo(
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider
value={{ duration, isOpen, isStreaming, setIsOpen }}
>
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
@@ -202,6 +205,8 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -212,7 +217,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown plugins={{ cjk, code, math, mermaid }} {...props}>
<Streamdown plugins={streamdownPlugins} {...props}>
{children}
</Streamdown>
</CollapsibleContent>
+49 -33
View File
@@ -10,7 +10,7 @@ import {
} from "@repo/shadcn-ui/components/ui/collapsible";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronRightIcon } from "lucide-react";
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo } from "react";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -64,38 +64,54 @@ export const SchemaDisplay = ({
className,
children,
...props
}: SchemaDisplayProps) => (
<SchemaDisplayContext.Provider
value={{ description, method, parameters, path, requestBody, responseBody }}
>
<div
className={cn(
"overflow-hidden rounded-lg border bg-background",
className
)}
{...props}
>
{children ?? (
<>
<SchemaDisplayHeader>
<div className="flex items-center gap-3">
<SchemaDisplayMethod />
<SchemaDisplayPath />
</div>
</SchemaDisplayHeader>
{description && <SchemaDisplayDescription />}
<SchemaDisplayContent>
{parameters && parameters.length > 0 && <SchemaDisplayParameters />}
{requestBody && requestBody.length > 0 && <SchemaDisplayRequest />}
{responseBody && responseBody.length > 0 && (
<SchemaDisplayResponse />
)}
</SchemaDisplayContent>
</>
)}
</div>
</SchemaDisplayContext.Provider>
);
}: SchemaDisplayProps) => {
const contextValue = useMemo(
() => ({
description,
method,
parameters,
path,
requestBody,
responseBody,
}),
[description, method, parameters, path, requestBody, responseBody]
);
return (
<SchemaDisplayContext.Provider value={contextValue}>
<div
className={cn(
"overflow-hidden rounded-lg border bg-background",
className
)}
{...props}
>
{children ?? (
<>
<SchemaDisplayHeader>
<div className="flex items-center gap-3">
<SchemaDisplayMethod />
<SchemaDisplayPath />
</div>
</SchemaDisplayHeader>
{description && <SchemaDisplayDescription />}
<SchemaDisplayContent>
{parameters && parameters.length > 0 && (
<SchemaDisplayParameters />
)}
{requestBody && requestBody.length > 0 && (
<SchemaDisplayRequest />
)}
{responseBody && responseBody.length > 0 && (
<SchemaDisplayResponse />
)}
</SchemaDisplayContent>
</>
)}
</div>
</SchemaDisplayContext.Provider>
);
};
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
+3 -2
View File
@@ -21,8 +21,9 @@ const ShimmerComponent = ({
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
const MotionComponent = useMemo(
() => motion.create(Component as keyof JSX.IntrinsicElements),
[Component]
);
const dynamicSpread = useMemo(
+50 -36
View File
@@ -97,18 +97,21 @@ export const SpeechInput = ({
}: SpeechInputProps) => {
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [mode, setMode] = useState<SpeechInputMode>("none");
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
null
);
const [mode] = useState<SpeechInputMode>(detectSpeechInputMode);
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const onTranscriptionChangeRef = useRef<
SpeechInputProps["onTranscriptionChange"]
>(onTranscriptionChange);
const onAudioRecordedRef =
useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
// Detect mode on mount
useEffect(() => {
setMode(detectSpeechInputMode());
}, []);
// Keep refs in sync
onTranscriptionChangeRef.current = onTranscriptionChange;
onAudioRecordedRef.current = onAudioRecorded;
// Initialize Speech Recognition when mode is speech-recognition
useEffect(() => {
@@ -148,13 +151,11 @@ export const SpeechInput = ({
}
if (finalTranscript) {
onTranscriptionChange?.(finalTranscript);
onTranscriptionChangeRef.current?.(finalTranscript);
}
};
const handleError = (event: Event) => {
const errorEvent = event as SpeechRecognitionErrorEvent;
console.error("Speech recognition error:", errorEvent.error);
const handleError = () => {
setIsListening(false);
};
@@ -164,30 +165,43 @@ export const SpeechInput = ({
speechRecognition.addEventListener("error", handleError);
recognitionRef.current = speechRecognition;
setRecognition(speechRecognition);
setIsRecognitionReady(true);
return () => {
speechRecognition.removeEventListener("start", handleStart);
speechRecognition.removeEventListener("end", handleEnd);
speechRecognition.removeEventListener("result", handleResult);
speechRecognition.removeEventListener("error", handleError);
if (recognitionRef.current) {
recognitionRef.current.stop();
}
speechRecognition.stop();
recognitionRef.current = null;
setIsRecognitionReady(false);
};
}, [mode, onTranscriptionChange, lang]);
}, [mode, lang]);
// Cleanup MediaRecorder and stream on unmount
useEffect(
() => () => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) {
track.stop();
}
}
},
[]
);
// Start MediaRecorder recording
const startMediaRecorder = useCallback(async () => {
if (!onAudioRecorded) {
console.warn(
"SpeechInput: onAudioRecorded callback is required for MediaRecorder fallback"
);
if (!onAudioRecordedRef.current) {
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mediaRecorder = new MediaRecorder(stream);
audioChunksRef.current = [];
@@ -201,32 +215,33 @@ export const SpeechInput = ({
for (const track of stream.getTracks()) {
track.stop();
}
streamRef.current = null;
const audioBlob = new Blob(audioChunksRef.current, {
type: "audio/webm",
});
if (audioBlob.size > 0) {
if (audioBlob.size > 0 && onAudioRecordedRef.current) {
setIsProcessing(true);
try {
const transcript = await onAudioRecorded(audioBlob);
const transcript = await onAudioRecordedRef.current(audioBlob);
if (transcript) {
onTranscriptionChange?.(transcript);
onTranscriptionChangeRef.current?.(transcript);
}
} catch (error) {
console.error("Transcription error:", error);
} catch {
// Error handling delegated to the onAudioRecorded caller
} finally {
setIsProcessing(false);
}
}
};
const handleError = (event: Event) => {
console.error("MediaRecorder error:", event);
const handleError = () => {
setIsListening(false);
for (const track of stream.getTracks()) {
track.stop();
}
streamRef.current = null;
};
mediaRecorder.addEventListener("dataavailable", handleDataAvailable);
@@ -236,11 +251,10 @@ export const SpeechInput = ({
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.start();
setIsListening(true);
} catch (error) {
console.error("Failed to start MediaRecorder:", error);
} catch {
setIsListening(false);
}
}, [onAudioRecorded, onTranscriptionChange]);
}, []);
// Stop MediaRecorder recording
const stopMediaRecorder = useCallback(() => {
@@ -251,11 +265,11 @@ export const SpeechInput = ({
}, []);
const toggleListening = useCallback(() => {
if (mode === "speech-recognition" && recognition) {
if (mode === "speech-recognition" && recognitionRef.current) {
if (isListening) {
recognition.stop();
recognitionRef.current.stop();
} else {
recognition.start();
recognitionRef.current.start();
}
} else if (mode === "media-recorder") {
if (isListening) {
@@ -264,12 +278,12 @@ export const SpeechInput = ({
startMediaRecorder();
}
}
}, [mode, recognition, isListening, startMediaRecorder, stopMediaRecorder]);
}, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
// Determine if button should be disabled
const isDisabled =
mode === "none" ||
(mode === "speech-recognition" && !recognition) ||
(mode === "speech-recognition" && !isRecognitionReady) ||
(mode === "media-recorder" && !onAudioRecorded) ||
isProcessing;
+14 -1
View File
@@ -21,7 +21,9 @@ import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -319,6 +321,7 @@ export const StackTraceCopyButton = memo(
...props
}: StackTraceCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { raw } = useStackTrace();
const copyToClipboard = useCallback(async () => {
@@ -331,12 +334,22 @@ export const StackTraceCopyButton = memo(
await navigator.clipboard.writeText(raw);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
} catch (error) {
onError?.(error as Error);
}
}, [raw, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
+44 -30
View File
@@ -11,6 +11,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -45,35 +46,40 @@ export const Terminal = ({
className,
children,
...props
}: TerminalProps) => (
<TerminalContext.Provider
value={{ autoScroll, isStreaming, onClear, output }}
>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
className
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<TerminalTitle />
<div className="flex items-center gap-1">
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</div>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
);
}: TerminalProps) => {
const contextValue = useMemo(
() => ({ autoScroll, isStreaming, onClear, output }),
[autoScroll, isStreaming, onClear, output]
);
return (
<TerminalContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
className
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<TerminalTitle />
<div className="flex items-center gap-1">
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</div>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
);
};
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
@@ -159,6 +165,7 @@ export const TerminalCopyButton = ({
...props
}: TerminalCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { output } = useContext(TerminalContext);
const copyToClipboard = useCallback(async () => {
@@ -171,12 +178,19 @@ export const TerminalCopyButton = ({
await navigator.clipboard.writeText(output);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [output, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
+55 -40
View File
@@ -16,7 +16,7 @@ import {
CircleIcon,
XCircleIcon,
} from "lucide-react";
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo } from "react";
type TestStatus = "passed" | "failed" | "skipped" | "running";
@@ -50,22 +50,26 @@ export const TestResults = ({
className,
children,
...props
}: TestResultsProps) => (
<TestResultsContext.Provider value={{ summary }}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children ??
(summary && (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
))}
</div>
</TestResultsContext.Provider>
);
}: TestResultsProps) => {
const contextValue = useMemo(() => ({ summary }), [summary]);
return (
<TestResultsContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children ??
(summary && (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
))}
</div>
</TestResultsContext.Provider>
);
};
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
@@ -228,13 +232,17 @@ export const TestSuite = ({
className,
children,
...props
}: TestSuiteProps) => (
<TestSuiteContext.Provider value={{ name, status }}>
<Collapsible className={cn("rounded-lg border", className)} {...props}>
{children}
</Collapsible>
</TestSuiteContext.Provider>
);
}: TestSuiteProps) => {
const contextValue = useMemo(() => ({ name, status }), [name, status]);
return (
<TestSuiteContext.Provider value={contextValue}>
<Collapsible className={cn("rounded-lg border", className)} {...props}>
{children}
</Collapsible>
</TestSuiteContext.Provider>
);
};
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
@@ -336,22 +344,29 @@ export const Test = ({
className,
children,
...props
}: TestProps) => (
<TestContext.Provider value={{ duration, name, status }}>
<div
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
{...props}
>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined && <TestDuration />}
</>
)}
</div>
</TestContext.Provider>
);
}: TestProps) => {
const contextValue = useMemo(
() => ({ duration, name, status }),
[duration, name, status]
);
return (
<TestContext.Provider value={contextValue}>
<div
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
{...props}
>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined && <TestDuration />}
</>
)}
</div>
</TestContext.Provider>
);
};
const statusStyles: Record<TestStatus, string> = {
failed: "text-red-600 dark:text-red-400",
+25 -27
View File
@@ -45,35 +45,33 @@ export type ToolHeaderProps = {
}
);
export const getStatusBadge = (status: ToolPart["state"]) => {
const labels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const icons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
+7 -9
View File
@@ -5,7 +5,7 @@ import type { ComponentProps, ReactNode } from "react";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { createContext, useCallback, useContext } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
type TranscriptionSegment = TranscriptionResult["segments"][number];
@@ -51,15 +51,13 @@ export const Transcription = ({
prop: externalCurrentTime,
});
const contextValue = useMemo(
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
[currentTime, onSeek, setCurrentTime, segments]
);
return (
<TranscriptionContext.Provider
value={{
currentTime,
onSeek,
onTimeUpdate: setCurrentTime,
segments,
}}
>
<TranscriptionContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-wrap gap-1 text-sm leading-relaxed",
+22 -14
View File
@@ -21,7 +21,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
@@ -57,17 +57,23 @@ export const WebPreview = ({
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const handleUrlChange = useCallback(
(newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
},
[onUrlChange]
);
const contextValue: WebPreviewContextValue = {
consoleOpen,
setConsoleOpen,
setUrl: handleUrlChange,
url,
};
const contextValue = useMemo<WebPreviewContextValue>(
() => ({
consoleOpen,
setConsoleOpen,
setUrl: handleUrlChange,
url,
}),
[consoleOpen, handleUrlChange, url]
);
return (
<WebPreviewContext.Provider value={contextValue}>
@@ -140,12 +146,14 @@ export const WebPreviewUrl = ({
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const [prevUrl, setPrevUrl] = useState(url);
const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally
useEffect(() => {
// Sync input value with context URL when it changes externally (derived state pattern)
if (url !== prevUrl) {
setPrevUrl(url);
setInputValue(url);
}, [url]);
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);