diff --git a/frontend-web/.env.network b/frontend-web/.env.network index d81d70e5..9f770d29 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -42,7 +42,7 @@ VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000 # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000 VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000 -VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AIPI_CONSUMER_URL=http://10.19.200.5:8002/consumer # A 系统跳转地址(人物/组织/事件等列表项打开链接) VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain # B 系统知识库 iframe 根地址 diff --git a/frontend-web/index.html b/frontend-web/index.html index 65c7d4e3..26b8f4a6 100644 --- a/frontend-web/index.html +++ b/frontend-web/index.html @@ -15,8 +15,9 @@ - CM 助手 - + CM 助手 + +
diff --git a/frontend-web/package.json b/frontend-web/package.json index 5ec6dacc..fb189ccb 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -10,7 +10,9 @@ "build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:chrome109-css": "node scripts/check-chrome109-css.mjs", + "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", @@ -110,6 +112,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.4", + "lightningcss": "^1.30.2", "postcss": "^8.5.3", "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", diff --git a/frontend-web/postcss.config.js b/frontend-web/postcss.config.js index e056d950..6137acd6 100644 --- a/frontend-web/postcss.config.js +++ b/frontend-web/postcss.config.js @@ -1,5 +1,11 @@ -export default { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; +import tailwindcss from "@tailwindcss/postcss"; +import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs"; +import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs"; + +export default { + plugins: [ + tailwindcss(), + legacyColorFallback(), + lightningCssLegacyChrome(), + ], +}; diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js new file mode 100644 index 00000000..f44dd78f --- /dev/null +++ b/frontend-web/public/runtime-config.js @@ -0,0 +1,96 @@ +// 前端运行时配置文件 +// +// 说明: +// 1. 本文件会在打包时原样复制到 dist/runtime-config.js。 +// 2. 应用启动时会优先读取 window.__CM_RUNTIME_CONFIG__ 里的配置。 +// 3. 部署后如果需要切换接口地址,直接修改 dist/runtime-config.js 即可,不需要重新 pnpm build。 +// 4. 这里的默认值来自 .env.network;如某个环境不用某项,可以留空字符串。 +// 5. 布尔开关也用字符串形式填写:"true" / "false"。 +window.__CM_RUNTIME_CONFIG__ = { + // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 + // VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", + + // LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。 + // VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api", + + // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 + VITE_CANVAS_LANGGRAPH_BASE_URL: "", + + // 轻应用「新建笔记」系列接口地址。 + VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000", + + // CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。 + VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + + // 佳来模型对话接口。 + VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", + + // 多轮检索/生成接口。 + VITE_AIPI_GENERATE_CHAT_URL: "https://ch1.b.uat.4.cn/jialai_b1_chat", + + // 任务问答接口。 + VITE_AIPI_TASK_CHAT_URL: "https://ch1.b.uat.4.cn/qq_b1_chat", + + // B 系统知识库前端地址:用于打开知识详情、原文详情等。 + VITE_AIPI_KNOWLEDGE_URL: "https://chlib.b.uat.4.cn/knowlibrary", + + // B1 COH 系统地址。 + VITE_AIPI_B1COH_URL: "https://ch1.b.uat.4.cn/web", + + // B2 行为体/网络资源系统地址。 + VITE_AIPI_B2ACTOR_URL: "https://ty.b.uat.4.cn", + + // B2 网络 XWT iframe 路径。 + VITE_AIPI_B2ACTOR_XWT_PATH: "/fromB/toXWT?isFrame=1", + + // 知识库 iframe 地址换取接口。 + VITE_AIPI_KNOWLEDGE_IFRAME_API_URL: "https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl", + + // 知识库 iframe 皮肤参数。 + VITE_AIPI_KNOWLEDGE_IFRAME_SKIN: "red", + + // MopeChat 接口地址。 + VITE_AIPI_MOPECHAT_URL: "https://ch1.b.uat.4.cn/likun_b1_mope", + + // 工作室/空间后端接口:空间列表、空间详情、素材上传等使用。 + VITE_AIPI_BACKENP_URL: "http://47.88.25.99:8000", + + // 工作室/空间后端别名:兼容旧配置命名,通常与 VITE_AIPI_BACKENP_URL 保持一致。 + VITE_NOTEBOOK_API_BASE_URL: "http://47.88.25.99:8000", + + // 工作室/空间前端地址:用于 auth/callback、新建对话等跳转。 + VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000", + + // Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。 + VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer", + + // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 + VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", + + // B 系统 knowes 根地址。 + VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes", + + // MaxKey 鉴权地址:onlineTicket 换 token 使用。 + VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn", + + // PG 中任务 iframe 地址。 + VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn", + + // XK 中任务 iframe 地址。 + VITE_AIPI_XKLINKTO_URL: "https://kz.b.uat.4.cn", + + // 是否使用真实任务看板数据:true=真实接口;false=本地 mock。 + VITE_USE_REAL_DATA: "true", + + // 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。 + VITE_MOCK_COP_TASK_DETAIL: "false", + + // 大屏绘制 selectSqReport 是否使用本地 mock:false=真实接口;true=本地 mock。 + VITE_MOCK_SQ_REPORT: "false", + + // 静态站点模式开关;一般部署业务系统时保持 false。 + VITE_STATIC_WEBSITE_ONLY: "false", + + // GitHub OAuth Token;不使用 GitHub 相关能力时留空。 + VITE_GITHUB_OAUTH_TOKEN: "", +}; diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs new file mode 100644 index 00000000..e1c5325a --- /dev/null +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import postcss from "postcss"; + +const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i; +const assetsDir = path.resolve("dist/assets"); + +if (!fs.existsSync(assetsDir)) { + throw new Error("dist/assets 不存在,请先运行 pnpm build"); +} + +const cssFiles = fs + .readdirSync(assetsDir) + .filter((file) => file.endsWith(".css")); + +if (cssFiles.length === 0) { + throw new Error("dist/assets 中没有 CSS 构建产物"); +} + +const failures = []; +let modernDeclarations = 0; + +const requiredLegacyFallbacks = [ + { + file: "src/components/ui/textarea.tsx", + pattern: /rows=\{rows\s*\?\?\s*3\}/, + description: "Textarea needs a native rows fallback", + }, + { + file: "src/components/ai-elements/prompt-input.tsx", + pattern: //, + description: "Prompt textarea must not rely on :has() for its parent height", + }, +]; + +for (const file of cssFiles) { + const root = postcss.parse( + fs.readFileSync(path.join(assetsDir, file), "utf8"), + { from: file }, + ); + + root.walkDecls((declaration) => { + if (!unsupportedColor.test(declaration.value)) return; + modernDeclarations += 1; + + let ancestor = declaration.parent; + while (ancestor) { + if ( + ancestor.type === "atrule" && + ancestor.name === "supports" && + unsupportedColor.test(ancestor.params) + ) { + return; + } + ancestor = ancestor.parent; + } + + let previous = declaration.prev(); + while (previous?.type === "comment") previous = previous.prev(); + const hasSafeFallback = + !declaration.prop.startsWith("--") && + previous?.type === "decl" && + previous.prop === declaration.prop && + !unsupportedColor.test(previous.value); + + if (!hasSafeFallback) { + failures.push( + `${file}:${declaration.source?.start?.line ?? "?"} ${declaration.prop}: ${declaration.value}`, + ); + } + }); +} + +function walk(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(directory, entry.name); + return entry.isDirectory() ? walk(fullPath) : [fullPath]; + }); +} + +for (const file of walk(path.resolve("src"))) { + if (!/\.[jt]sx?$/.test(file)) continue; + const source = fs.readFileSync(file, "utf8"); + if (unsupportedColor.test(source)) { + failures.push( + `${path.relative(process.cwd(), file)} contains a runtime-only modern color function`, + ); + } +} + +for (const fallback of requiredLegacyFallbacks) { + const source = fs.readFileSync(path.resolve(fallback.file), "utf8"); + if (!fallback.pattern.test(source)) { + failures.push(`${fallback.file}: ${fallback.description}`); + } +} + +if (failures.length > 0) { + console.error("Legacy Chrome CSS compatibility check failed:\n"); + console.error(failures.slice(0, 50).join("\n")); + if (failures.length > 50) { + console.error(`... and ${failures.length - 50} more`); + } + process.exit(1); +} + +console.log( + `Legacy Chrome (100+) CSS compatibility check passed (${modernDeclarations} guarded modern declarations).`, +); diff --git a/frontend-web/scripts/postcss-legacy-color-fallback.mjs b/frontend-web/scripts/postcss-legacy-color-fallback.mjs new file mode 100644 index 00000000..7bbc339f --- /dev/null +++ b/frontend-web/scripts/postcss-legacy-color-fallback.mjs @@ -0,0 +1,70 @@ +/** + * Tailwind 4 gracefully falls back from color-mix() to the base semantic + * color, but that turns utilities such as bg-primary/5 into an opaque fill on + * Chrome 109. Replace that fallback with an sRGB-channel based alpha color. + * The modern color-mix() declaration remains in its @supports block. + */ + +const LEGACY_RGB_TOKENS = new Set([ + "accent", + "background", + "border", + "brand-200", + "card", + "destructive", + "foreground", + "input", + "muted", + "muted-foreground", + "primary", + "primary-foreground", + "ring", + "secondary", + "sidebar-accent", + "sidebar-border", + "sidebar-foreground", + "workspace-brand", +]); + +const TRANSPARENT_MIX_RE = + /color-mix\(\s*in\s+[\w-]+\s*,\s*var\(--([\w-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)/i; + +function legacyAlphaColor(value) { + const match = value.match(TRANSPARENT_MIX_RE); + if (!match || !LEGACY_RGB_TOKENS.has(match[1])) return null; + + const [, token, percentage] = match; + return `rgb(var(--legacy-${token}-rgb) / ${percentage}%)`; +} + +export default function legacyColorFallback() { + return { + postcssPlugin: "postcss-legacy-color-fallback", + OnceExit(root) { + root.walkAtRules("supports", (atRule) => { + if (!atRule.params.includes("color-mix")) return; + + atRule.walkDecls((modernDeclaration) => { + const fallbackValue = legacyAlphaColor(modernDeclaration.value); + if (!fallbackValue) return; + + // Tailwind emits the old-browser declaration immediately before + // the nested @supports block, in the same utility/variant rule. + let fallbackDeclaration = atRule.prev(); + while (fallbackDeclaration?.type === "comment") { + fallbackDeclaration = fallbackDeclaration.prev(); + } + + if ( + fallbackDeclaration?.type === "decl" && + fallbackDeclaration.prop === modernDeclaration.prop + ) { + fallbackDeclaration.value = fallbackValue; + } + }); + }); + }, + }; +} + +legacyColorFallback.postcss = true; diff --git a/frontend-web/scripts/postcss-lightningcss-chrome109.mjs b/frontend-web/scripts/postcss-lightningcss-chrome109.mjs new file mode 100644 index 00000000..da1ea398 --- /dev/null +++ b/frontend-web/scripts/postcss-lightningcss-chrome109.mjs @@ -0,0 +1,33 @@ +import postcss from "postcss"; +import { transform } from "lightningcss"; + +const LEGACY_CHROME = 100 << 16; + +/** + * Keep Vite's PostCSS pipeline (required by Tailwind 4), then compile the + * resulting CSS to Chrome 100-compatible syntax for both dev and production. + */ +export default function lightningCssLegacyChrome() { + return { + postcssPlugin: "postcss-lightningcss-legacy-chrome", + OnceExit(root, { result }) { + const filename = root.source?.input.file ?? result.opts.from ?? "style.css"; + const output = transform({ + filename, + code: Buffer.from(root.toString()), + targets: { chrome: LEGACY_CHROME }, + minify: false, + }); + const compiled = postcss.parse(output.code.toString(), { from: filename }); + + root.removeAll(); + root.append(compiled.nodes); + + for (const warning of output.warnings) { + result.warn(warning.message, { plugin: "lightningcss" }); + } + }, + }; +} + +lightningCssLegacyChrome.postcss = true; diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx index c15ce7c9..a796b775 100644 --- a/frontend-web/src/components/ai-elements/prompt-input.tsx +++ b/frontend-web/src/components/ai-elements/prompt-input.tsx @@ -846,7 +846,10 @@ export const PromptInput = ({ ref={formRef} {...props} > - {children} + {/* Do not rely on InputGroup's :has(textarea) selector here: Chrome + versions before 105 ignore :has(), retain h-9, and clip the whole + multi-line composer to a single row. */} + {children} ); @@ -873,16 +876,48 @@ export type PromptInputTextareaProps = ComponentProps< typeof InputGroupTextarea >; -export const PromptInputTextarea = ({ - onChange, - className, - placeholder = "What would you like to know?", - ...props -}: PromptInputTextareaProps) => { - const controller = useOptionalPromptInputController(); - const attachments = usePromptInputAttachments(); - const sanitizeIncomingFiles = usePromptInputValidation(); - const [isComposing, setIsComposing] = useState(false); +export const PromptInputTextarea = ({ + onChange, + onInput, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const sanitizeIncomingFiles = usePromptInputValidation(); + const [isComposing, setIsComposing] = useState(false); + const textareaRef = useRef(null); + + const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => { + if ( + typeof CSS !== "undefined" && + typeof CSS.supports === "function" && + CSS.supports("field-sizing", "content") + ) { + return; + } + + // Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem + // autosize range used by min-h-16/max-h-48 instead of leaving the prompt + // stuck at its minimum height. + textarea.style.height = "auto"; + const height = Math.max(64, Math.min(textarea.scrollHeight, 192)); + textarea.style.height = `${height}px`; + textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden"; + }, []); + + // onInput does not run for a controlled value filled by a suggested question + // or restored draft. Resize once after every render in legacy Chrome too. + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const frame = window.requestAnimationFrame(() => { + resizeForLegacyChrome(textarea); + }); + return () => window.cancelAnimationFrame(frame); + }, [controller?.textInput.value, resizeForLegacyChrome]); const handleKeyDown: KeyboardEventHandler = (e) => { if (e.key === "Enter") { @@ -962,12 +997,18 @@ export const PromptInputTextarea = ({ }; return ( - setIsComposing(false)} onCompositionStart={() => setIsComposing(true)} - onKeyDown={handleKeyDown} + onKeyDown={handleKeyDown} + onInput={(event) => { + resizeForLegacyChrome(event.currentTarget); + onInput?.(event); + }} onPaste={handlePaste} placeholder={placeholder} {...props} diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx index 78e314fe..83722413 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -26,6 +26,7 @@ import { import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; +import { readRuntimeEnv } from "@/env"; import { queryClient } from "@/components/query-client-provider"; import { Checkbox } from "@/components/ui/checkbox"; import Empty from "./empty"; @@ -303,11 +304,11 @@ const DocumentUploadPopupContent: FC<{ const searchSpaceId = String(searchSpace?.id); // 从环境变量获取配置 - const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001"; - const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || "http://ch1.b.uat.4.cn/consumer"; - const presentUrl = import.meta.env.VITE_AIPI_PRESENT_URL || "http://present.a.uat.4.cn/present/domain"; - const knowesUrl = import.meta.env.VITE_AIPI_KNOWES_URL || "http://ch1.b.uat.4.cn/knowes"; - const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL || "http://chlib.b.uat.4.cn/knowlibrary"; + const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || "http://localhost:8001"; + const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || "http://ch1.b.uat.4.cn/consumer"; + const presentUrl = readRuntimeEnv("VITE_AIPI_PRESENT_URL") || "http://present.a.uat.4.cn/present/domain"; + const knowesUrl = readRuntimeEnv("VITE_AIPI_KNOWES_URL") || "http://ch1.b.uat.4.cn/knowes"; + const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL") || "http://chlib.b.uat.4.cn/knowlibrary"; const [searchText, setSearchText] = useState(""); const [searchValue, setSearchValue] = useState(""); diff --git a/frontend-web/src/components/assistant-ui/network-actor.tsx b/frontend-web/src/components/assistant-ui/network-actor.tsx index a48576cb..e1b6ce0d 100644 --- a/frontend-web/src/components/assistant-ui/network-actor.tsx +++ b/frontend-web/src/components/assistant-ui/network-actor.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup"; +import { readRuntimeEnv } from "@/env"; interface NetworkActorProps { @@ -186,9 +187,9 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc const [actorCount, setActorCount] = useState(0); const [directionType, setDirectionType] = useState('TW'); const directionValue = useRef('TW'); - const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || 'https://ch1.b.uat.4.cn/consumer'; + const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer'; const backendUrl = typeof window !== 'undefined' - ? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001') + ? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001') : 'http://localhost:8001'; const [selectedSearchSources, setSelectedSearchSources] = useState([]); const [isImporting, setIsImporting] = useState(false); diff --git a/frontend-web/src/components/landing/progressive-skills-animation.tsx b/frontend-web/src/components/landing/progressive-skills-animation.tsx index 0b423331..b3638f9c 100644 --- a/frontend-web/src/components/landing/progressive-skills-animation.tsx +++ b/frontend-web/src/components/landing/progressive-skills-animation.tsx @@ -508,8 +508,9 @@ export default function ProgressiveSkillsAnimation() { "deploying", "done", ].includes(phase) && ( -
- ✨ Found 3 skills +
+ + Found 3 skills
)} @@ -526,8 +527,9 @@ export default function ProgressiveSkillsAnimation() { ].includes(phase) && (

-
- 🔬 Researching... +
+ + Researching...
{/* Loading SKILL.md */} @@ -623,7 +625,10 @@ export default function ProgressiveSkillsAnimation() { className="mt-4" >
-
🔨 Building...
+
+ + Building... +
Loading frontend-design/SKILL.md... @@ -653,7 +658,10 @@ export default function ProgressiveSkillsAnimation() { className="mt-4" >
-
🚀 Deploying...
+
+ + Deploying... +
@@ -676,8 +684,9 @@ export default function ProgressiveSkillsAnimation() { animate={{ opacity: 1, scale: 1 }} className="mt-4 rounded-xl border border-green-500/30 bg-green-500/10 p-4" > -
- ✅ Live at biotech-startup.vercel.app +
+ + Live at biotech-startup.vercel.app
)} diff --git a/frontend-web/src/components/landing/sections/sandbox-section.tsx b/frontend-web/src/components/landing/sections/sandbox-section.tsx index 1b916980..d9e05da8 100644 --- a/frontend-web/src/components/landing/sections/sandbox-section.tsx +++ b/frontend-web/src/components/landing/sections/sandbox-section.tsx @@ -35,27 +35,27 @@ export function SandboxSection({ className }: { className?: string }) { $ pip install -r requirements.txt - ✔ Installed pygame + [ok] Installed pygame $ write game.py --lines 156 - ✔ Written 156 lines + [ok] Written 156 lines $ python game.py --test - ✔ All sprites loaded + [ok] All sprites loaded - ✔ Physics engine OK + [ok] Physics engine OK - ✔ 60 FPS stable + [ok] 60 FPS stable {/* Scene 2: Data Analysis */} diff --git a/frontend-web/src/components/ui/input-group.tsx b/frontend-web/src/components/ui/input-group.tsx index ec7272b4..2d7eff81 100644 --- a/frontend-web/src/components/ui/input-group.tsx +++ b/frontend-web/src/components/ui/input-group.tsx @@ -144,21 +144,24 @@ function InputGroupInput({ ); } -function InputGroupTextarea({ - className, - ...props -}: React.ComponentProps<"textarea">) { +const InputGroupTextarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<"textarea"> +>(function InputGroupTextarea({ className, ...props }, ref) { return ( -