mirror of
https://github.com/vercel/ai-elements.git
synced 2026-09-14 19:48:26 +08:00
React improvements
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"ai-elements": patch
|
||||
---
|
||||
|
||||
React improvements
|
||||
+11
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user