Fix component bugs across hooks, elements, form, layout, editors and utils

- Hooks: per-instance timers in useReady; cleanup for useIntersectionObserver;
  useWebSocket no longer reconnects after unmount/refresh, drains the queue
  and dispatches non-JSON messages
- Modal/Popover/Dropdown: onClose only on close, Escape to close, no stray
  DOM props, hooks before early return, scrollbar-aware positioning,
  top-left/top-right dropdown positions
- Elements: Toast/Search/StarRating per-instance timers, Pagination labels
  and empty state, Breadcrumbs titles, LinkList dividers/props, segment-wise
  active-link matching (new utils/link-matches-path), Table object cells
- Form: Select with a default option is changeable, Input clear notifies
  controlled parents and copy works on textareas, Checkbox random label,
  NumberInputButtons runaway interval, upload prop/spread fixes
- Layout: Button disabled while loading, Stack gap and Span truncate via
  inline styles, Img fallbackImageSrc, className/JSDoc fixes
- Editors/MDX/AI: TinyMCE id/cleanup/stale handler/duplicate scripts,
  AceEditor debounce, markdown heights and frontmatter regex, last
  assistant message, AIPromptBlock prompt ref
- Utils: fetchApi headers/FormData/fetchOptions/no mutation, base64 file
  names and read errors, slugify underscore trim, serializeQuery zero values
- Tests for fetchApi, link matching, slugify and serializeQuery
- AGENTS.md updated for the package layout

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 11da9b89e7
commit cdeeb8201e
56 changed files with 1128 additions and 722 deletions
+231 -228
View File
@@ -1,228 +1,231 @@
import React, { type ComponentProps, type ReactNode } from "react";
import Link from "../layout/Link";
import Divider from "../layout/Divider";
import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = {
title: string;
path: string;
};
type Props = {
excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
};
/**
* # TWUI Breadcrumbs
* @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button`
*/
export default function Breadcrumbs({
excludeRegexMatch,
linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
: null,
);
React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname;
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
excludeRegexMatch,
skipHome,
});
setLinks(validPathLinks);
return function () {
setLinks(null);
};
}, []);
if (!links?.[1]) {
return <React.Fragment></React.Fragment>;
}
return (
<nav
className={twMerge(
"overflow-x-auto",
"twui-current-breadcrumb-wrapper",
)}
aria-label="Breadcrumb"
>
<Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full",
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
"twui-breadcrumbs-back-button",
backButtonProps?.className,
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
beforeIcon={<ChevronLeft size={20} />}
/>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
title={
currentLinkProps?.title || linkObject.title
}
>
{currentTitle || linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
>
{currentLinkProps?.title ||
linkObject.title}
</Link>
{divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
);
}
})}
</Row>
</nav>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
export function twuiBreadcrumbsGenerateLinksFromUrl({
url,
excludeRegexMatch,
skipHome,
}: {
url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText?.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}
import React, { type ComponentProps, type ReactNode } from "react";
import Link from "../layout/Link";
import Divider from "../layout/Divider";
import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = {
title: string;
path: string;
};
type Props = {
excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
};
/**
* # TWUI Breadcrumbs
* @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button`
*/
export default function Breadcrumbs({
excludeRegexMatch,
linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({
url: pageUrl,
excludeRegexMatch,
skipHome,
})
: null,
);
React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname;
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
excludeRegexMatch,
skipHome,
});
setLinks(validPathLinks);
return function () {
setLinks(null);
};
}, []);
if (!links?.[1]) {
return <React.Fragment></React.Fragment>;
}
return (
<nav
className={twMerge(
"overflow-x-auto",
"twui-current-breadcrumb-wrapper",
)}
aria-label="Breadcrumb"
>
<Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full",
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
"twui-breadcrumbs-back-button",
backButtonProps?.className,
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
beforeIcon={<ChevronLeft size={20} />}
/>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
title={
currentLinkProps?.title || linkObject.title
}
>
{currentTitle || linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
>
{linkObject.title}
</Link>
{divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
);
}
})}
</Row>
</nav>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
export function twuiBreadcrumbsGenerateLinksFromUrl({
url,
excludeRegexMatch,
skipHome,
}: {
url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText?.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}
+14
View File
@@ -106,6 +106,11 @@ export default function Dropdown({
React.useEffect(() => {
setMounted(true);
return () => {
window.clearTimeout(timeoutRef.current);
window.clearTimeout(openTimeoutRef.current);
};
}, []);
/**
@@ -146,6 +151,15 @@ export default function Dropdown({
case "bottom-right":
style = { right: viewportWidth - rect.right, top: rect.bottom };
break;
case "top-left":
style = { left: rect.left, bottom: viewportHeight - rect.top };
break;
case "top-right":
style = {
right: viewportWidth - rect.right,
bottom: viewportHeight - rect.top,
};
break;
case "top":
style = {
left: centerX,
+7 -10
View File
@@ -6,6 +6,7 @@ import React, {
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
import twuiLinkMatchesPath from "../utils/link-matches-path";
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
@@ -84,17 +85,13 @@ export function twuiAddActiveLinksFn({
const linkAttr = linkEl.getAttribute("href");
if (window.location.pathname === "/" && linkAttr == "/") {
linkEl.classList.add("active");
} else if (
isLinkStrict &&
linkEl.getAttribute("href") === window.location.pathname
) {
linkEl.classList.add("active");
} else if (
if (
linkAttr &&
window.location.pathname.startsWith(linkAttr) &&
!isLinkStrict
twuiLinkMatchesPath({
href: linkAttr,
pathname: window.location.pathname,
strict: Boolean(isLinkStrict),
})
) {
linkEl.classList.add("active");
}
+9 -7
View File
@@ -57,6 +57,9 @@ export default function LinkList({
...props
}: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(null);
const finalLinks = links
.flat()
.filter((ln): ln is TWUI_LINK_LIST_LINK_OBJECT => Boolean(ln));
React.useEffect(() => {
twuiAddActiveLinksFn({
@@ -75,12 +78,7 @@ export default function LinkList({
props.className,
)}
>
{links
.flat()
.filter((ln) => Boolean(ln))
.map((link, index) => {
if (!link) return null;
{finalLinks.map((link, index) => {
if (link.divider)
return (
<React.Fragment key={index}>
@@ -89,7 +87,7 @@ export default function LinkList({
);
const finalDivider =
index < links.length - 1 &&
index < finalLinks.length - 1 &&
(dividerComponent ? (
dividerComponent
) : divider ? (
@@ -107,9 +105,12 @@ export default function LinkList({
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
buttonProps?.className,
link.buttonProps?.className,
)}
onClick={(e) => {
link.onClick?.(e);
buttonProps?.onClick?.(e);
link.buttonProps?.onClick?.(e);
}}
>
@@ -138,6 +139,7 @@ export default function LinkList({
strict={link.strict}
onClick={(e) => {
link.onClick?.(e);
linkProps?.onClick?.(e);
link.linkProps?.onClick?.(e);
}}
>
+6 -2
View File
@@ -83,16 +83,20 @@ export default function Modal(props: TWUI_MODAL_PROPS) {
}
}, []);
const wasOpenRef = React.useRef(open);
React.useEffect(() => {
existingSetOpen?.(open);
if (open == false) onClose?.();
if (wasOpenRef.current && !open) onClose?.();
wasOpenRef.current = open;
}, [open]);
React.useEffect(() => {
setOpen(existingOpen || false);
}, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
const internalTargetRef = React.useRef<HTMLDivElement>(null);
const finalTargetRef = targetRef || internalTargetRef;
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
+9 -13
View File
@@ -26,10 +26,10 @@ export default function Pagination({
limit,
...props
}: Props) {
if (!count || !page || !limit)
return (
<EmptyContent title={`count, page, and limit are all required`} />
);
if (!page || !limit)
return <EmptyContent title={`page and limit are both required`} />;
if (!count) return null;
const isLimit = limit * page >= count;
@@ -44,10 +44,8 @@ export default function Pagination({
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
isLimit ? "opacity-40 pointer-events-none" : "",
)}
className="p-1"
disabled={isLimit}
>
<ChevronRight size={20} />
</Button>
@@ -63,17 +61,15 @@ export default function Pagination({
>
{pages > 1 && (
<Button
title="Next Page Button"
title="Previous Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev - 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
page == 1 ? "opacity-40 pointer-events-none" : "",
)}
className="p-1"
disabled={page == 1}
>
<ChevronLeft size={20} />
</Button>
+57 -49
View File
@@ -1,49 +1,57 @@
import React from "react";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = {
content: string;
refresh?: number;
};
/**
* # CodeBlock
*
* @className `twui-remote-code-block-wrapper`
*/
export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents();
React.useEffect(() => {
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
}).then((mdxSrc) => {
setMdxSource(mdxSrc);
});
}, [refresh]);
if (!mdxSource) {
return null;
}
return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}
import React from "react";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = {
content: string;
refresh?: number;
};
/**
* # CodeBlock
*
* @className `twui-remote-code-block-wrapper`
*/
export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents();
React.useEffect(() => {
let cancelled = false;
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
})
.then((mdxSrc) => {
if (!cancelled) setMdxSource(mdxSrc);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [content, refresh]);
if (!mdxSource) {
return null;
}
return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}
+9 -6
View File
@@ -9,8 +9,6 @@ import React, {
type ReactNode,
} from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
@@ -50,16 +48,21 @@ export default function Search<KeyType extends string>({
props.defaultValue?.toString() || "",
);
React.useEffect(() => {
clearTimeout(timeout);
const timeoutRef = React.useRef<any>(null);
timeout = setTimeout(() => {
React.useEffect(() => {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
dispatch?.(input);
changeHandler?.(input);
}, delay);
return () => window.clearTimeout(timeoutRef.current);
}, [input]);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const internalInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = componentRef || internalInputRef;
// React.useEffect(() => {
// if (props.autoFocus) {
+7 -6
View File
@@ -18,8 +18,6 @@ export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
> &
StarProps;
let timeout: any;
/**
* # Star Rating Component
* @className_wrapper twui-star-rating
@@ -41,12 +39,15 @@ export default function StarRating({
const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false);
const timeoutRef = React.useRef<any>(null);
React.useEffect(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setValueExternal?.(finalValue);
window.clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setValueExternal?.(selectedStarValue);
}, 500);
return () => window.clearTimeout(timeoutRef.current);
}, [selectedStarValue]);
return (
@@ -148,6 +149,7 @@ function StarComponent({
>
<Star
size={size}
{...starProps}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
@@ -156,7 +158,6 @@ function StarComponent({
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className,
)}
{...starProps}
/>
</div>
);
+14 -2
View File
@@ -6,6 +6,18 @@ type Props = {
data?: { [k: string]: any }[];
};
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
export default function Table({ data }: Props) {
if (!data || data.length === 0) {
return (
@@ -62,9 +74,9 @@ export default function Table({ data }: Props) {
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
"overflow-ellipsis",
)}
title={row[header]}
title={formatCellValue(row[header])}
>
{row[header]}
{formatCellValue(row[header])}
</td>
))}
</tr>
+6 -8
View File
@@ -19,9 +19,6 @@ export type TWUIToastProps = DetailedHTMLProps<
color?: (typeof ToastStyles)[number];
};
let interval: any;
let timeout: any;
/**
* # Toast Component
* @className twui-toast-root
@@ -38,6 +35,7 @@ export default function Toast({
...props
}: TWUIToastProps) {
const [ready, setReady] = React.useState(false);
const timeoutRef = React.useRef<any>(null);
const IDName = "twui-toast-root";
React.useEffect(() => {
@@ -56,14 +54,13 @@ export default function Toast({
React.useEffect(() => {
if (!ready || !open) return;
timeout = setTimeout(() => {
timeoutRef.current = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
return function () {
setOpen?.(false);
closeDispatch?.(open);
window.clearTimeout(timeoutRef.current);
};
}, [ready, open]);
@@ -85,10 +82,11 @@ export default function Toast({
"twui-toast",
)}
onMouseEnter={() => {
window.clearTimeout(timeout);
window.clearTimeout(timeoutRef.current);
}}
onMouseLeave={(e) => {
timeout = setTimeout(() => {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
@@ -90,6 +90,7 @@ export default function AIPromptActionSection({
loading={loading}
className="p-2"
onClick={() => {
if (!prompt.trim()) return;
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
+1 -2
View File
@@ -28,9 +28,7 @@ export default function AIPromptBlock({
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
@@ -64,6 +62,7 @@ export default function AIPromptBlock({
mdRes,
setMdRes,
placeholder,
currentPromptRef,
}}
/>
</Stack>
@@ -13,6 +13,8 @@ type Props = {
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
/** Receives the last submitted prompt */
currentPromptRef?: React.MutableRefObject<string>;
};
export default function AIPromptBlockInputSection({
@@ -23,9 +25,12 @@ export default function AIPromptBlockInputSection({
mdRes = "",
setMdRes,
placeholder,
currentPromptRef: externalCurrentPromptRef,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const internalCurrentPromptRef = React.useRef("");
const currentPromptRef =
externalCurrentPromptRef || internalCurrentPromptRef;
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
@@ -44,6 +49,7 @@ export default function AIPromptBlockInputSection({
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
if (!prompt.trim() || loading) return;
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
+4 -3
View File
@@ -53,9 +53,10 @@ export default function AIVoiceTranscription({
onClick={(e) => {
e.preventDefault();
const history_last_response = history?.find(
(h) => h.role == "assistant",
);
const history_last_response = history
?.slice()
.reverse()
.find((h) => h.role == "assistant");
voice_transcription_fn({
last_message:
last_response ||