diff --git a/crates/agent-gateway/web/src/components/RemotePathPickerModal.tsx b/crates/agent-gateway/web/src/components/RemotePathPickerModal.tsx index 111a8f1d9..258fd7be1 100644 --- a/crates/agent-gateway/web/src/components/RemotePathPickerModal.tsx +++ b/crates/agent-gateway/web/src/components/RemotePathPickerModal.tsx @@ -1,4 +1,3 @@ -import { Dialog } from "@base-ui/react/dialog"; import { invoke } from "@liveagent/app/shims/tauriCore"; import { AlertTriangle, @@ -11,6 +10,14 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { Input } from "@liveagent/ui/components/ui/input"; import { useLocale } from "@liveagent/ui/i18n/index"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -146,10 +153,7 @@ export function RemotePathPickerModal(props: RemotePathPickerModalProps) { const [creatingFolder, setCreatingFolder] = useState(false); const [createFolderError, setCreateFolderError] = useState(null); const didExpandInitialPathRef = useRef(false); - // Base UI 控制 open:先置 false 播放退场过渡,onOpenChangeComplete 再通知 - // 调用方卸载(onSelect 已先行 resolve,onClose 的 resolve(null) 为 no-op)。 const [open, setOpen] = useState(true); - const requestClose = useCallback(() => setOpen(false), []); const modalTitle = title ?? (mode === "file" ? t("settings.filePickerTitle") : t("settings.workdirPickerTitle")); @@ -512,201 +516,195 @@ export function RemotePathPickerModal(props: RemotePathPickerModalProps) { } } - // 挂 z-[120]:需要压过新建 worktree 弹窗(z-[110])等上层来源;作为嵌套 - // Base UI Dialog 打开时 DOM 顺序也保证在后。settings-modal-* 类只复用其 - // 响应式布局规则,动画由 modal-dialog-* 的 Base UI 过渡属性驱动。 return ( - { if (!nextOpen) onClose(); }} > - - - - -
-
- {mode === "file" ? ( - - ) : ( - - )} -
-
- {modalTitle} - - {modalDescription} - -
- + /> + } + > + + +
+ +
+
+
+ {mode === "file" ? t("settings.pathPickerPathLabel") : t("settings.workdir")}
- -
-
-
- {mode === "file" ? t("settings.pathPickerPathLabel") : t("settings.workdir")} -
- -
+ +
+
+ +
+
+
+ + ~
- -
-
-
- - ~ -
- · -
- - Root -
-
- - {mode === "directory" ? ( -
-
- { - setNewFolderName(event.currentTarget.value); - if (createFolderError) { - setCreateFolderError(null); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - void createFolderInActivePath(); - }} - placeholder={ - activePath - ? t("settings.newFolderNamePlaceholder") - : t("settings.newFolderSelectParentFirst") - } - disabled={!activePath || creatingFolder} - className="h-9" - /> - -
- {createFolderError ? ( -
- - {createFolderError} -
- ) : null} -
- ) : null} - -
- item.data.label} - viewState={viewState} - onExpandItem={(item, treeId) => { - const index = typeof item.index === "string" ? item.index : ""; - if (!index || index === ROOT_ID) return; - updateTreeViewState(treeId, (treePrev) => ({ - ...treePrev, - expandedItems: treePrev.expandedItems?.includes(index) - ? treePrev.expandedItems - : [...(treePrev.expandedItems ?? []), index], - })); - void loadChildren(index); - }} - onCollapseItem={(item, treeId) => { - const index = typeof item.index === "string" ? item.index : ""; - if (!index || index === ROOT_ID) return; - updateTreeViewState(treeId, (treePrev) => ({ - ...treePrev, - expandedItems: (treePrev.expandedItems ?? []).filter( - (entry) => entry !== index, - ), - })); - }} - onSelectItems={(selectedItems, treeId) => { - updateTreeViewState(treeId, (treePrev) => ({ - ...treePrev, - selectedItems, - })); + · +
+ + Root +
+
+ + {mode === "directory" ? ( +
+
+ { + setNewFolderName(event.currentTarget.value); + if (createFolderError) { + setCreateFolderError(null); + } }} - onFocusItem={(item, treeId) => { - updateTreeViewState(treeId, (treePrev) => ({ - ...treePrev, - focusedItem: item.index, - })); + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void createFolderInActivePath(); }} + placeholder={ + activePath + ? t("settings.newFolderNamePlaceholder") + : t("settings.newFolderSelectParentFirst") + } + disabled={!activePath || creatingFolder} + className="h-9" + /> +
- - {statusLine ? ( -
- {statusLine.kind === "loading" ? ( - - ) : statusLine.kind === "error" ? ( - + {creatingFolder ? ( + ) : ( - + )} - {statusLine.text} + {t("settings.createFolder")} + +
+ {createFolderError ? ( +
+ + {createFolderError}
) : null}
+ ) : null} + +
+ item.data.label} + viewState={viewState} + onExpandItem={(item, treeId) => { + const index = typeof item.index === "string" ? item.index : ""; + if (!index || index === ROOT_ID) return; + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + expandedItems: treePrev.expandedItems?.includes(index) + ? treePrev.expandedItems + : [...(treePrev.expandedItems ?? []), index], + })); + void loadChildren(index); + }} + onCollapseItem={(item, treeId) => { + const index = typeof item.index === "string" ? item.index : ""; + if (!index || index === ROOT_ID) return; + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + expandedItems: (treePrev.expandedItems ?? []).filter((entry) => entry !== index), + })); + }} + onSelectItems={(selectedItems, treeId) => { + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + selectedItems, + })); + }} + onFocusItem={(item, treeId) => { + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + focusedItem: item.index, + })); + }} + > + + +
+ + {statusLine ? ( +
+ {statusLine.kind === "loading" ? ( + + ) : statusLine.kind === "error" ? ( + + ) : ( + + )} + {statusLine.text} +
+ ) : null} +
-
- + + }>{t("settings.cancel")} + { if (!canConfirm || !selectedPath) return; onSelect(selectedPath); - requestClose(); }} - > - {t("settings.select")} - -
- - - - + /> + } + > + {t("settings.select")} + + + + ); } diff --git a/crates/agent-gateway/web/src/pages/settings/DevicesSection.tsx b/crates/agent-gateway/web/src/pages/settings/DevicesSection.tsx index 1321a67ae..5d0b2f770 100644 --- a/crates/agent-gateway/web/src/pages/settings/DevicesSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/DevicesSection.tsx @@ -15,12 +15,17 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { Input } from "@liveagent/ui/components/ui/input"; import { Label } from "@liveagent/ui/components/ui/label"; import { useLocale } from "@liveagent/ui/i18n/index"; import { ConfirmActionPopover } from "@liveagent/ui/pages/settings/shared"; import { useCallback, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { type AdminAgentEntry, type AdminAgentStatus, @@ -387,116 +392,92 @@ function AddClientDialog({ }) { const { t } = useLocale(); - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape" && !loading) onClose(); - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [loading, onClose]); - - return createPortal( -
-
- -
-
- {error ? ( -
- - {error} +
+ {error ? ( +
+ + {error} +
+ ) : null} +
+ + onNameChange(event.currentTarget.value)} + /> +
+
+ + onAgentIdChange(event.currentTarget.value)} + />
- ) : null} -
- - onNameChange(event.currentTarget.value)} - /> -
-
- - onAgentIdChange(event.currentTarget.value)} - />
-
-
- - -
- -
, - document.body, +
+ + +
+ + + ); } @@ -510,14 +491,6 @@ function IssuedTokenDialog({ const { t } = useLocale(); const [copied, setCopied] = useState(false); - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - async function handleCopy() { if (!navigator.clipboard?.writeText) return; await navigator.clipboard.writeText(issuedToken.token); @@ -525,33 +498,19 @@ function IssuedTokenDialog({ window.setTimeout(() => setCopied(false), 1800); } - return createPortal( -
-
-
-
, - document.body, + + ); } diff --git a/crates/agent-gateway/web/src/shims/browserPathPrompt.tsx b/crates/agent-gateway/web/src/shims/browserPathPrompt.tsx new file mode 100644 index 000000000..637188f8f --- /dev/null +++ b/crates/agent-gateway/web/src/shims/browserPathPrompt.tsx @@ -0,0 +1,88 @@ +import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; +import { Input } from "@liveagent/ui/components/ui/input"; +import { Label } from "@liveagent/ui/components/ui/label"; +import { useState } from "react"; +import { createRoot } from "react-dom/client"; + +type BrowserPathPromptOptions = { + title: string; + description: string; + label: string; + placeholder: string; + inputId: string; +}; + +function BrowserPathPromptDialog(props: { + options: BrowserPathPromptOptions; + onResolve: (value: string | null) => void; +}) { + const { options, onResolve } = props; + const [value, setValue] = useState(""); + + return ( + !open && onResolve(null)}> + +
{ + event.preventDefault(); + const path = value.trim(); + if (path) onResolve(path); + }} + > +
+ {options.title} + {options.description} +
+
+ + setValue(event.currentTarget.value)} + /> +
+
+ + +
+
+
+
+ ); +} + +export function promptPathInBrowser(options: BrowserPathPromptOptions): Promise { + if (typeof document === "undefined" || !document.body) return Promise.resolve(null); + + return new Promise((resolve) => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let settled = false; + + const finish = (value: string | null) => { + if (settled) return; + settled = true; + queueMicrotask(() => { + root.unmount(); + host.remove(); + resolve(value); + }); + }; + + root.render(); + }); +} diff --git a/crates/agent-gateway/web/src/shims/tauriCore.ts b/crates/agent-gateway/web/src/shims/tauriCore.ts index bec00270f..2e8cc0ec0 100644 --- a/crates/agent-gateway/web/src/shims/tauriCore.ts +++ b/crates/agent-gateway/web/src/shims/tauriCore.ts @@ -1,5 +1,6 @@ import { getGatewayWebSocketClient } from "../lib/gatewaySocket"; import { loadToken } from "../lib/storage"; +import { promptPathInBrowser } from "./browserPathPrompt"; type GatewayRuntimeStatus = { online: boolean; @@ -64,117 +65,6 @@ async function invokeGatewayMemory(command: string, args?: Record { - if (typeof window === "undefined" || typeof document === "undefined" || !document.body) { - return Promise.resolve(null); - } - - return new Promise((resolve) => { - const overlay = document.createElement("div"); - overlay.className = - "fixed inset-0 z-[120] flex items-center justify-center bg-black/55 p-4 backdrop-blur-sm"; - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", options.title); - - const panel = document.createElement("form"); - panel.className = - "relative z-10 w-full max-w-md overflow-hidden rounded-2xl border border-border/70 bg-background shadow-2xl"; - - const header = document.createElement("div"); - header.className = "border-b border-border/60 px-5 py-4"; - - const title = document.createElement("div"); - title.className = "text-base font-semibold text-foreground"; - title.textContent = options.title; - - const description = document.createElement("div"); - description.className = "mt-1 text-xs text-muted-foreground"; - description.textContent = options.description; - - const body = document.createElement("div"); - body.className = "space-y-2 px-5 py-5"; - - const label = document.createElement("label"); - label.className = "block text-xs font-medium text-muted-foreground"; - label.htmlFor = options.inputId; - label.textContent = options.label; - - const input = document.createElement("input"); - input.id = options.inputId; - input.className = - "h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/70 focus:border-ring focus:ring-2 focus:ring-ring/20"; - input.placeholder = options.placeholder; - input.type = "text"; - - const footer = document.createElement("div"); - footer.className = - "flex flex-col-reverse gap-2 border-t border-border/60 bg-muted/20 px-5 py-4 sm:flex-row sm:justify-end"; - - const cancelButton = document.createElement("button"); - cancelButton.type = "button"; - cancelButton.className = - "inline-flex h-9 items-center justify-center rounded-lg border border-input bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted sm:w-auto"; - cancelButton.textContent = "取消"; - - const confirmButton = document.createElement("button"); - confirmButton.type = "submit"; - confirmButton.className = - "inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 sm:w-auto"; - confirmButton.disabled = true; - confirmButton.textContent = "确认"; - let closed = false; - - const cleanup = (value: string | null) => { - if (closed) return; - closed = true; - window.removeEventListener("keydown", handleKeyDown); - overlay.remove(); - resolve(value); - }; - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - cleanup(null); - } - } - - input.addEventListener("input", () => { - confirmButton.disabled = input.value.trim().length === 0; - }); - cancelButton.addEventListener("click", () => cleanup(null)); - overlay.addEventListener("click", (event) => { - if (event.target === overlay) { - cleanup(null); - } - }); - panel.addEventListener("submit", (event) => { - event.preventDefault(); - const value = input.value.trim(); - if (value) { - cleanup(value); - } - }); - window.addEventListener("keydown", handleKeyDown); - - header.append(title, description); - body.append(label, input); - footer.append(cancelButton, confirmButton); - panel.append(header, body, footer); - overlay.append(panel); - document.body.append(overlay); - window.requestAnimationFrame(() => input.focus()); - }); -} - async function pickWorkdirInBrowser(): Promise { return promptPathInBrowser({ title: "选择工作目录", diff --git a/crates/agent-gateway/web/src/styles.css b/crates/agent-gateway/web/src/styles.css index 96e342689..3519ba966 100644 --- a/crates/agent-gateway/web/src/styles.css +++ b/crates/agent-gateway/web/src/styles.css @@ -1155,60 +1155,6 @@ html[data-liveagent-webui="gateway"] .settings-confirm-popover { max-width: calc(100vw - 24px); } -html[data-liveagent-webui="gateway"] .history-share-modal-overlay, -html[data-liveagent-webui="gateway"] .external-link-modal-overlay { - opacity: 0; - transition: opacity 180ms ease; - will-change: opacity; -} - -html[data-liveagent-webui="gateway"] .history-share-modal-overlay[data-state="open"], -html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="open"] { - opacity: 1; -} - -html[data-liveagent-webui="gateway"] .history-share-modal-overlay[data-state="closed"], -html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="closed"] { - pointer-events: none; -} - -html[data-liveagent-webui="gateway"] .history-share-modal-panel, -html[data-liveagent-webui="gateway"] .external-link-modal-panel { - opacity: 0; - transform: translateY(12px) scale(0.985); - transition: - opacity 180ms ease, - transform 180ms cubic-bezier(0.22, 1, 0.36, 1); - will-change: opacity, transform; -} - -html[data-liveagent-webui="gateway"] - .history-share-modal-overlay[data-state="open"] - .history-share-modal-panel, -html[data-liveagent-webui="gateway"] - .external-link-modal-overlay[data-state="open"] - .external-link-modal-panel { - opacity: 1; - transform: translateY(0) scale(1); -} - -html[data-liveagent-webui="gateway"] - .history-share-modal-overlay[data-state="closed"] - .history-share-modal-panel, -html[data-liveagent-webui="gateway"] - .external-link-modal-overlay[data-state="closed"] - .external-link-modal-panel { - opacity: 0; - transform: translateY(8px) scale(0.985); -} - -@media (prefers-reduced-motion: reduce) { - html[data-liveagent-webui="gateway"] .external-link-modal-overlay, - html[data-liveagent-webui="gateway"] .external-link-modal-panel { - transition-duration: 1ms; - } -} - /* ─── Login Page — macOS Frosted Glass ──────────────────────────── */ .login-shell { @@ -2495,11 +2441,6 @@ html[data-liveagent-webui="gateway"] .project-terminal-viewport .xterm-scrollabl flex: 1 1 8rem; } - html[data-liveagent-webui="gateway"] .settings-modal-overlay { - align-items: stretch; - padding: max(8px, env(safe-area-inset-top)) 8px max(8px, env(safe-area-inset-bottom)); - } - html[data-liveagent-webui="gateway"] .settings-modal-panel { display: flex; width: min(100%, calc(100vw - 16px)) !important; diff --git a/crates/agent-gui/src/agent-ui-adapters/providerSettings.tsx b/crates/agent-gui/src/agent-ui-adapters/providerSettings.tsx index 7503416c6..d9e4b85f6 100644 --- a/crates/agent-gui/src/agent-ui-adapters/providerSettings.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/providerSettings.tsx @@ -8,6 +8,12 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -24,7 +30,6 @@ import { } from "@liveagent/ui/pages/settings/providerUtils"; import { invoke } from "@tauri-apps/api/core"; import { useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import ccswitchLogoUrl from "../../src-tauri/icons/custom/ccswitch.png"; import cherryStudioLogoUrl from "../../src-tauri/icons/custom/cherrystudio.png"; import type { ProviderModelConfig } from "../lib/settings"; @@ -251,22 +256,16 @@ function CcsImportModal(props: { }); } - return createPortal( -
-
-
- , - document.body, + + ); } diff --git a/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx b/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx index fb1e9b12b..91cd134df 100644 --- a/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx +++ b/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx @@ -10,11 +10,15 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { Input } from "@liveagent/ui/components/ui/input"; -import { useAnimatedPresence } from "@liveagent/ui/lib/shared/modalMotion"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import type { CodexRequestFormat, ProviderId } from "../../lib/settings"; export type CherryProviderImportItem = { @@ -99,7 +103,6 @@ export function CherryStudioImportModal(props: CherryStudioImportModalProps) { const candidates = response.providers; const resolvedDataPath = dataPath ?? response.dataPath ?? ""; const [pathDialogOpen, setPathDialogOpen] = useState(false); - const pathDialog = useAnimatedPresence(pathDialogOpen); const hasSyncableItems = useMemo( () => candidates.some((item) => item.enabled && item.importable), [candidates], @@ -154,21 +157,15 @@ export function CherryStudioImportModal(props: CherryStudioImportModalProps) { }); } - return createPortal( -
-
- - - {pathDialog.shouldRender ? ( -
-
) : null} - - - ) : null} - , - document.body, + + + + ); } diff --git a/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs b/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs index f3b1e323a..4e8ea5ff2 100644 --- a/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs +++ b/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs @@ -49,6 +49,12 @@ function createMarkdownModule(expanded) { }, "./ui/button": { Button: "Button" }, "./ui/copy-button": { CopyButton: "CopyButton" }, + "./ui/dialog": { + Dialog: "Dialog", + DialogContent: "DialogContent", + DialogDescription: "DialogDescription", + DialogTitle: "DialogTitle", + }, }, }); return loader.loadModule("@liveagent/ui/components/Markdown.tsx"); diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index a42148af2..1718683a4 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -47,16 +47,17 @@ const loader = createTsModuleLoader({ throw new Error("openUrl mock was not expected to be called"); }, }, - "react-dom": { - createPortal(children, container) { - return { type: "portal", children, container }; - }, - }, "./ui/button": { Button(props) { return { type: "Button", props }; }, }, + "./ui/dialog": { + Dialog: "Dialog", + DialogContent: "DialogContent", + DialogDescription: "DialogDescription", + DialogTitle: "DialogTitle", + }, "../lib/shared/utils": { cn: (...parts) => parts.filter(Boolean).join(" "), }, @@ -407,25 +408,23 @@ test("ordinary https links stay on the external-link path", () => { assert.equal(routed.type.name, "MarkdownExternalLink"); }); -test("external link safety modal renders through document body portal", () => { +test("external link safety modal uses the shared dialog primitives", () => { const previousDocument = globalThis.document; - const body = { nodeType: 1 }; - globalThis.document = { body }; + globalThis.document = {}; try { - const portal = markdownModule.ExternalLinkModal({ + const dialog = markdownModule.ExternalLinkModal({ isOpen: true, onClose() {}, onConfirm() {}, url: "https://example.com/dashboard", }); - assert.ok(portal); - assert.equal(portal.type, "portal"); - assert.equal(portal.container, body); - assert.equal(portal.children.type, "div"); - assert.match(portal.children.props.className, /\bfixed\b/); - assert.match(portal.children.props.className, /\binset-0\b/); + assert.ok(dialog); + assert.equal(dialog.type, "Dialog"); + assert.equal(dialog.props.open, true); + assert.equal(dialog.props.children.type, "DialogContent"); + assert.match(dialog.props.children.props.className, /external-link-modal-panel/); } finally { if (typeof previousDocument === "undefined") { delete globalThis.document; diff --git a/crates/agent-ui/src/components/Markdown.tsx b/crates/agent-ui/src/components/Markdown.tsx index 74896a432..80a27a9ae 100644 --- a/crates/agent-ui/src/components/Markdown.tsx +++ b/crates/agent-ui/src/components/Markdown.tsx @@ -15,7 +15,6 @@ import { useMemo, useState, } from "react"; -import { createPortal } from "react-dom"; import remarkBreaks from "remark-breaks"; import { type Components, @@ -40,6 +39,7 @@ import { normalizeLatexDelimiters } from "../lib/normalizeLatexDelimiters"; import { cn } from "../lib/shared/utils"; import { Button } from "./ui/button"; import { CopyButton } from "./ui/copy-button"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; const CHAT_FILE_NODE_DATA_KEY = "liveagentChatFileLink"; const LIVEAGENT_FILE_PROTOCOL = "liveagent-file:"; @@ -558,32 +558,23 @@ export function ExternalLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafet } }; - const modal = ( -
-
- - + + ); - - return createPortal(modal, document.body); } const MARKDOWN_EMBED_CLASSNAME = cn( diff --git a/crates/agent-ui/src/components/chat/HistoryShareModal.tsx b/crates/agent-ui/src/components/chat/HistoryShareModal.tsx index d2e8e29c7..fb517b8fa 100644 --- a/crates/agent-ui/src/components/chat/HistoryShareModal.tsx +++ b/crates/agent-ui/src/components/chat/HistoryShareModal.tsx @@ -10,10 +10,15 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { buildShareUrl, resolveShareOrigin } from "@liveagent/ui/lib/chat/historyShareOrigin"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useEffect, useId, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; type ShareConversation = { title: string; @@ -182,29 +187,22 @@ export function HistoryShareModal({ }); } - return createPortal( -
-
- -
+ return ( + !open && onClose()}> +
-
分享会话
-
分享会话 + {conversation.title} -
+
-
-
, - document.body, + + ); } diff --git a/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx b/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx index 8c57f9a01..e28ccbdf6 100644 --- a/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx +++ b/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx @@ -13,11 +13,16 @@ import { X, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { useLocale } from "@liveagent/ui/i18n/index"; import { buildShareUrl, resolveShareOrigin } from "@liveagent/ui/lib/chat/historyShareOrigin"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useMemo, useState } from "react"; -import { createPortal } from "react-dom"; export type ManagedHistoryShareStatus = { conversationId?: string; @@ -217,16 +222,9 @@ export function SharedHistoryManagerModal setCopiedId(null)); } - return createPortal( -
-
- -
+ return ( + !open && onClose()}> +
@@ -234,12 +232,12 @@ export function SharedHistoryManagerModal
-
+ {t("sharedHistory.title")} -
-
+ + {t("sharedHistory.subtitle")} -
+
-
-
, - document.body, + + ); } diff --git a/crates/agent-ui/src/components/chat/WorkspaceCloneModal.tsx b/crates/agent-ui/src/components/chat/WorkspaceCloneModal.tsx index 76b7d4b1b..f7cb2dc93 100644 --- a/crates/agent-ui/src/components/chat/WorkspaceCloneModal.tsx +++ b/crates/agent-ui/src/components/chat/WorkspaceCloneModal.tsx @@ -1,6 +1,12 @@ import { useDirectoryPicker } from "@liveagent/adapters/directoryPicker"; import { FolderOpen, GitBranch, Loader2, X } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { Input } from "@liveagent/ui/components/ui/input"; import { Label } from "@liveagent/ui/components/ui/label"; import { @@ -11,9 +17,7 @@ import { SelectValue, } from "@liveagent/ui/components/ui/select"; import { useLocale } from "@liveagent/ui/i18n/index"; -import { useModalMotion } from "@liveagent/ui/lib/shared/modalMotion"; import { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; type RemoteBranches = { defaultBranch: string; @@ -58,7 +62,6 @@ export function WorkspaceCloneModal({ const [name, setName] = useState(""); const [error, setError] = useState(""); const [cloning, setCloning] = useState(false); - const { modalState, requestClose } = useModalMotion(onClose); const [nameIsAutomatic, setNameIsAutomatic] = useState(true); const [branch, setBranch] = useState(""); @@ -76,17 +79,6 @@ export function WorkspaceCloneModal({ !cloning, ); - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - // defaultPrevented: an open branch Select consumes Escape to close itself. - if (event.key !== "Escape" || event.defaultPrevented || cloning) return; - event.preventDefault(); - requestClose(); - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [cloning, requestClose]); - async function chooseParent() { try { const selected = await pickDirectory(parent); @@ -144,7 +136,7 @@ export function WorkspaceCloneModal({ setError(""); try { await onClone(remoteUrl.trim(), parent.trim(), name.trim(), branch); - requestClose(); + onClose(); } catch (reason) { setError(errorMessage(reason)); } finally { @@ -152,39 +144,27 @@ export function WorkspaceCloneModal({ } } - const modal = createPortal( -
{ + if (!open && !cloning) onClose(); + }} > -
@@ -196,7 +176,7 @@ export function WorkspaceCloneModal({ className="h-auto w-full justify-start gap-3 rounded-2xl p-4 text-left" onClick={() => { onOpenFolder(); - requestClose(); + onClose(); }} > @@ -313,7 +293,7 @@ export function WorkspaceCloneModal({ ) : null} {error ?

{error}

: null}
-
-
- , - document.body, + + ); return ( diff --git a/crates/agent-ui/src/components/git/GitBranchSelector.tsx b/crates/agent-ui/src/components/git/GitBranchSelector.tsx index 77fdf5eb7..d6badd476 100644 --- a/crates/agent-ui/src/components/git/GitBranchSelector.tsx +++ b/crates/agent-ui/src/components/git/GitBranchSelector.tsx @@ -1,4 +1,3 @@ -import { Dialog } from "@base-ui/react/dialog"; import { useDirectoryPicker } from "@liveagent/adapters/directoryPicker"; import { Check, @@ -23,6 +22,13 @@ import { } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -48,7 +54,6 @@ import { cn } from "@liveagent/ui/lib/shared/utils"; import type { WorkspaceActivityClient } from "@liveagent/ui/lib/workspace-activity/types"; import { useWorkspaceInvalidation } from "@liveagent/ui/lib/workspace-activity/useWorkspaceInvalidation"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import type { GitBranch as GitBranchInfo, GitClient, @@ -157,132 +162,129 @@ function GitInitModal(props: { onSubmit, } = props; const { t } = useLocale(); - const titleId = useId(); const branchId = useId(); const userNameId = useId(); const userEmailId = useId(); - if (!open) return null; - - return createPortal( -
{ + if (!nextOpen && !loading) onClose(); + }} > -
-
{ - event.preventDefault(); - onSubmit(); - }} + -
-
-
- -
-
-
- {t("git.branchSelector.initRepositoryTitle")} + { + event.preventDefault(); + onSubmit(); + }} + > +
+
+
+
-
- {t("git.branchSelector.initRepositoryDescription")} +
+ + {t("git.branchSelector.initRepositoryTitle")} + + + {t("git.branchSelector.initRepositoryDescription")} +
-
- -
-
-
- -
- {workdir} -
-
-
- - onBranchChange(event.target.value)} - className="git-branch-selector-input h-9 text-sm" - placeholder="main" - autoFocus +
-
+
-
-
-
- {error ? ( -
- {error} +
+
+ + onUserNameChange(event.target.value)} + className="git-branch-selector-input h-9 text-sm" + disabled={loading} + /> +
+
+ + onUserEmailChange(event.target.value)} + className="git-branch-selector-input h-9 text-sm" + disabled={loading} + /> +
- ) : null} -
-
- - -
- -
, - document.body, + {error ? ( +
+ {error} +
+ ) : null} +
+
+ + +
+ + + ); } @@ -340,205 +342,201 @@ function WorktreeCreateModal(props: { } } - // directoryPickerElement 渲染在 Root 内、Portal 外:WebUI 的远程路径选择器 - // 由此成为嵌套 Base UI Dialog,天然叠在本弹窗之上(GUI 为原生目录选择器,元素为 null)。 return ( - { if (!nextOpen && !loading) onClose(); }} > - - - - { - event.preventDefault(); - onSubmit(); - }} - /> - } - > -
-
-
- -
-
- }> - {t("git.branchSelector.createWorktreeTitle")} - - } - > - {t("git.branchSelector.worktreeDescription")} - -
+ +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+
+
+
-
+
+ + } + > + + +
+
+
+ +
- - + {repoRoot} +
-
+
+ + +
+
-
-
-
-
- - onBranchChange(event.target.value)} - className="h-9 text-sm" - placeholder={t("git.branchSelector.worktreeBranchPlaceholder")} - autoFocus - disabled={loading} - spellCheck={false} - autoCapitalize="none" - autoCorrect="off" - /> -
-
- - onDirectoryNameChange(event.target.value)} - className="h-9 text-sm" - placeholder={t("git.branchSelector.worktreeDirectoryPlaceholder")} - disabled={loading} - spellCheck={false} - autoCapitalize="none" - autoCorrect="off" - /> -
+ onDirectoryNameChange(event.target.value)} + className="h-9 text-sm" + placeholder={t("git.branchSelector.worktreeDirectoryPlaceholder")} + disabled={loading} + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + />
-
- -
- - {parentDirectory ? ( - - ) : null} +
+
+ +
+ + {parentDirectory ? ( -
-
-
- {parentDirectory - ? t("git.branchSelector.worktreeCustomLocationHint") - : t("git.branchSelector.worktreeLocationHint")} + ) : null} +
- {error ? ( -
- {error} -
- ) : null}
-
- - +
+ {parentDirectory + ? t("git.branchSelector.worktreeCustomLocationHint") + : t("git.branchSelector.worktreeLocationHint")}
- - - - {directoryPickerElement} - + {error ? ( +
+ {error} +
+ ) : null} +
+
+ } + > + {t("chat.cancel")} + + +
+ + {/* Web 端目录选择器是嵌套 Dialog;置于父 Popup 内交给 Base UI 管理层级与焦点。 */} + {directoryPickerElement} + + ); } -// Per-branch action sheet opened from a branch row's "⋯" button. Lives below -// the shared ConfirmDialog (z-[120]) so delete confirmations stack above it. +// Per-branch action dialog opened from a branch row's "⋯" button. function BranchActionsModal(props: { action: GitBranchActionState | null; canWrite: boolean; @@ -577,7 +575,6 @@ function BranchActionsModal(props: { onClose, } = props; const { t } = useLocale(); - const titleId = useId(); const inputId = useId(); if (!action) return null; @@ -593,205 +590,197 @@ function BranchActionsModal(props: { ? t("git.branchSelector.renameBranch") : t("git.branchSelector.createFromHere"); - return createPortal( -
{ + if (!nextOpen && !busy) onClose(); + }} > -
-
{ - event.preventDefault(); - if (isForm) onSubmit(); - }} - > -
-
-
- -
-
-
- {branch.fullName} + + { + event.preventDefault(); + if (isForm) onSubmit(); + }} + > +
+
+
+
-
- {isForm ? formTitle : kindLabel} +
+ + {branch.fullName} + + + {isForm ? formTitle : kindLabel} +
-
- -
- {mode === "menu" ? ( -
- {canWrite ? ( - - ) : null} - {canWrite && isLocal ? ( - - ) : null} - - {canWrite && isLocal && !branch.current ? ( - checkedOutWorktreePath && onDeleteWorktree ? ( + + +
+ {mode === "menu" ? ( +
+ {canWrite ? ( - ) : ( + ) : null} + {canWrite && isLocal ? ( - ) - ) : null} - {error ? ( -
- {error} -
- ) : null} -
- ) : ( -
- {mode === "createFrom" ? ( + ) : null} + + {canWrite && isLocal && !branch.current ? ( + checkedOutWorktreePath && onDeleteWorktree ? ( + + ) : ( + + ) + ) : null} + {error ? ( +
+ {error} +
+ ) : null} +
+ ) : ( +
+ {mode === "createFrom" ? ( +
+ +
+ {branch.fullName} +
+
+ ) : null}
-
- ) : null} -
- - onDraftChange(event.target.value)} - onKeyDown={(event) => { - // Keep keystrokes local to the sheet; Escape steps back to - // the action list instead of dismissing the whole dialog. - event.stopPropagation(); - if (event.nativeEvent.isComposing) return; - if (event.key === "Escape") { - event.preventDefault(); - onBack(); + onDraftChange(event.target.value)} + onKeyDown={(event) => { + // Keep keystrokes local to the sheet; Escape steps back to + // the action list instead of dismissing the whole dialog. + event.stopPropagation(); + if (event.nativeEvent.isComposing) return; + if (event.key === "Escape") { + event.preventDefault(); + onBack(); + } + }} + placeholder={ + mode === "rename" + ? t("git.branchSelector.renamePlaceholder") + : t("git.branchSelector.newBranchPlaceholder") } - }} - placeholder={ - mode === "rename" - ? t("git.branchSelector.renamePlaceholder") - : t("git.branchSelector.newBranchPlaceholder") - } - className="h-8 text-xs" - autoFocus - disabled={busy} - /> -
- {error ? ( -
- {error} + className="h-8 text-xs" + autoFocus + disabled={busy} + />
- ) : null} -
- )} - {isForm ? ( -
- - -
- ) : null} - -
, - document.body, + {error ? ( +
+ {error} +
+ ) : null} +
+ )} + {isForm ? ( +
+ + +
+ ) : null} + + + ); } diff --git a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx index d3ed9cc4f..0e9c2e959 100644 --- a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx @@ -19,7 +19,6 @@ import { useRef, useState, } from "react"; -import { createPortal } from "react-dom"; import { clearManagedProcesses, readManagedProcessLog, @@ -30,6 +29,7 @@ import { import type { ManagedProcessLog, ManagedProcessRecord } from "../../lib/managed-process/types"; import { cn } from "../../lib/shared/utils"; import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog"; type BackgroundTasksPanelProps = { // Visibility contract from the right dock: gates the per-second uptime @@ -81,8 +81,6 @@ function processCopyText(process: ManagedProcessRecord) { ].join("\n"); } -// Portal modal following the mirrored confirm-dialog shell: bottom sheet on -// small (touch) viewports, centered card from `sm:` up. function BackgroundTaskLogDialog(props: { process: ManagedProcessRecord; actionsDisabled: boolean; @@ -118,19 +116,6 @@ function BackgroundTaskLogDialog(props: { refresh(); }, [refresh]); - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key !== "Escape") return; - if (contextMenu) { - setContextMenu(null); - } else { - onClose(); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [contextMenu, onClose]); - const handleLogContextMenu = useCallback((event: ReactMouseEvent) => { // Replace the native menu with our own; stopPropagation also keeps the // desktop AppChrome's global contextmenu suppression out of the loop. @@ -175,34 +160,29 @@ function BackgroundTaskLogDialog(props: { if (log?.content) copyToClipboard(log.content); }, [copyToClipboard, log?.content]); - return createPortal( -
{ + if (open) return; + if (contextMenu) setContextMenu(null); + else onClose(); + }} > -
- - {contextMenu ? ( -
- - - + + + +
-
- ) : null} -
, - document.body, + ) : null} + + ); } diff --git a/crates/agent-ui/src/components/project-tools/SshPortForwardDialog.tsx b/crates/agent-ui/src/components/project-tools/SshPortForwardDialog.tsx index ba254d371..ed07bd6f8 100644 --- a/crates/agent-ui/src/components/project-tools/SshPortForwardDialog.tsx +++ b/crates/agent-ui/src/components/project-tools/SshPortForwardDialog.tsx @@ -1,4 +1,3 @@ -import { Dialog } from "@base-ui/react/dialog"; import { Cable, Loader2, X } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/index"; import { useRef, useState } from "react"; @@ -11,6 +10,7 @@ import { validateSshLocalForwardTarget, } from "../../lib/terminal/sshLocalForwardTypes"; import { Button } from "../ui/button"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog"; export type SshPortForwardDialogProps = { sessionId: string; @@ -110,157 +110,152 @@ export function SshPortForwardDialog(props: SshPortForwardDialogProps) { const previewPort = remotePort || "?"; return ( - { if (!open) onClose(); }} > - - - - -
-
-
- -
-
- - {t("projectTools.sshLocalForwardModalTitle")} - -
- {subtitle} -
-
-
+ +
+
+
+ +
+
+ + {t("projectTools.sshLocalForwardModalTitle")} + + + {subtitle} + +
+
+ + + } + > + + +
- - } +
{ + event.preventDefault(); + handleSubmit(); + }} + > +
+
+
+ + { + const value = event.currentTarget.value; + if (!isSshLocalForwardPortDraft(value)) return; + setLocalPort(value); + setError(""); + }} + onBlur={handleLocalPortBlur} + className={`${FIELD_CLASS} font-mono`} + placeholder={t("projectTools.sshLocalForwardAutoPort")} + disabled={submitting} + /> +
+
+ + { + const value = event.currentTarget.value; + if (!isSshLocalForwardPortDraft(value)) return; + setRemotePort(value); + setError(""); + }} + className={`${FIELD_CLASS} font-mono`} + placeholder={t("projectTools.sshLocalForwardRemotePortLabel")} + disabled={submitting} + /> +
+
+
+ + { + setRemoteHost(event.currentTarget.value); + setError(""); + }} + className={FIELD_CLASS} + placeholder={t("projectTools.sshLocalForwardHostPlaceholder")} + disabled={submitting} + />
- { - event.preventDefault(); - handleSubmit(); - }} - > -
-
-
- - { - const value = event.currentTarget.value; - if (!isSshLocalForwardPortDraft(value)) return; - setLocalPort(value); - setError(""); - }} - onBlur={handleLocalPortBlur} - className={`${FIELD_CLASS} font-mono`} - placeholder={t("projectTools.sshLocalForwardAutoPort")} - disabled={submitting} - /> -
-
- - { - const value = event.currentTarget.value; - if (!isSshLocalForwardPortDraft(value)) return; - setRemotePort(value); - setError(""); - }} - className={`${FIELD_CLASS} font-mono`} - placeholder={t("projectTools.sshLocalForwardRemotePortLabel")} - disabled={submitting} - /> -
-
-
- - { - setRemoteHost(event.currentTarget.value); - setError(""); - }} - className={FIELD_CLASS} - placeholder={t("projectTools.sshLocalForwardHostPlaceholder")} - disabled={submitting} - /> -
- -
- 127.0.0.1:{previewLocal} - - {previewHost}:{previewPort} -
+
+ 127.0.0.1:{previewLocal} + + {previewHost}:{previewPort} +
-
- {t("projectTools.sshLocalForwardHelp")} -
+
+ {t("projectTools.sshLocalForwardHelp")} +
- {error ? ( -
- {error} -
- ) : null} + {error ? ( +
+ {error}
+ ) : null} +
-
- } - > - {t("projectTools.sshLocalForwardCancel")} - - -
- - - - - +
+ } + > + {t("projectTools.sshLocalForwardCancel")} + + +
+ + + ); } diff --git a/crates/agent-ui/src/components/project-tools/SshTunnelPanel.tsx b/crates/agent-ui/src/components/project-tools/SshTunnelPanel.tsx index 1e212ecb7..cd5e57847 100644 --- a/crates/agent-ui/src/components/project-tools/SshTunnelPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/SshTunnelPanel.tsx @@ -27,6 +27,12 @@ import { import { SshPortForwardDialog } from "@liveagent/ui/components/project-tools/SshPortForwardDialog"; import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -1549,98 +1555,104 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) {
- {prompt ? ( -
-
{ - event.preventDefault(); - handleSubmitPrompt(); - }} - > -
-
- -
-
-
- {hostKeyPrompt - ? t("projectTools.sshTunnelPromptTitle") - : t("projectTools.sshTunnelAuthPromptTitle")} + { + if (!open) handleCancelPrompt(); + }} + > + + {prompt ? ( + { + event.preventDefault(); + handleSubmitPrompt(); + }} + > +
+
+
-
- {prompt.message} +
+ + {hostKeyPrompt + ? t("projectTools.sshTunnelPromptTitle") + : t("projectTools.sshTunnelAuthPromptTitle")} + + + {prompt.message} +
-
-
-
- - {t("projectTools.sshTunnelHost")} - - - {prompt.host}:{prompt.port} - -
- {prompt.keyType ? ( +
- {t("projectTools.sshTunnelKeyType")} + {t("projectTools.sshTunnelHost")} - {prompt.keyType} - -
- ) : null} - {prompt.fingerprintSha256 ? ( -
- - {t("projectTools.sshTunnelFingerprint")} - - - {prompt.fingerprintSha256} + {prompt.host}:{prompt.port}
+ {prompt.keyType ? ( +
+ + {t("projectTools.sshTunnelKeyType")} + + + {prompt.keyType} + +
+ ) : null} + {prompt.fingerprintSha256 ? ( +
+ + {t("projectTools.sshTunnelFingerprint")} + + + {prompt.fingerprintSha256} + +
+ ) : null} +
+ {!hostKeyPrompt ? ( + setPromptAnswer(event.currentTarget.value)} + className="mt-3 h-10 w-full rounded-lg border border-border/70 bg-background/80 px-3 text-[calc(11px*var(--zone-font-scale,1))] text-foreground outline-none transition-colors placeholder:text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-muted-foreground/70 focus-visible:border-emerald-500/50 focus-visible:ring-1 focus-visible:ring-emerald-500/20" + type={prompt.answerEcho ? "text" : "password"} + aria-label={t("projectTools.sshTunnelAuthPromptTitle")} + autoFocus + /> ) : null} -
- {!hostKeyPrompt ? ( - setPromptAnswer(event.currentTarget.value)} - className="mt-3 h-10 w-full rounded-lg border border-border/70 bg-background/80 px-3 text-[calc(11px*var(--zone-font-scale,1))] text-foreground outline-none transition-colors placeholder:text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-muted-foreground/70 focus-visible:border-emerald-500/50 focus-visible:ring-1 focus-visible:ring-emerald-500/20" - type={prompt.answerEcho ? "text" : "password"} - aria-label={t("projectTools.sshTunnelAuthPromptTitle")} - autoFocus - /> - ) : null} -
- - -
- -
- ) : null} +
+ + +
+ + ) : null} + + {closeSessionConfirmDialog} {forwardModalSession ? ( { + if (!nextOpen && !loading) onClose(); + }} > -
-
{ - event.preventDefault(); - onSubmit(); - }} - > -
-
- {t("projectTools.gitReview.remoteSetupTitle")} -
-
- {t(remoteSetupDescriptionKey(action))} + + { + event.preventDefault(); + onSubmit(); + }} + > +
+ + {t("projectTools.gitReview.remoteSetupTitle")} + + + {t(remoteSetupDescriptionKey(action))} +
-
-
-
-
- {branch} +
+
+
+ {branch} +
+
+ {workdir} +
-
- {workdir} +
+ + onRemoteUrlChange(event.target.value)} + className="h-9 text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-[calc(11px*var(--zone-font-scale,1))]" + placeholder={t("projectTools.gitReview.remoteUrlPlaceholder")} + autoFocus + disabled={loading} + />
+ {error ? ( +
+ {error} +
+ ) : null}
-
- - onRemoteUrlChange(event.target.value)} - className="h-9 text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-[calc(11px*var(--zone-font-scale,1))]" - placeholder={t("projectTools.gitReview.remoteUrlPlaceholder")} - autoFocus - disabled={loading} - /> +
+ +
- {error ? ( -
- {error} -
- ) : null} -
-
- - -
- -
, - document.body, + + + ); } @@ -179,8 +178,6 @@ export function GitDiscardConfirmModal(props: { }) { const { target, loading, onClose, onConfirm } = props; const { t } = useLocale(); - const titleId = useId(); - if (!target) return null; const isAll = target.kind === "all"; @@ -191,27 +188,23 @@ export function GitDiscardConfirmModal(props: { ? t("projectTools.gitReview.discardAllConfirm") : t("projectTools.gitReview.discardConfirm").replace("{path}", target.path); - return createPortal( -
{ + if (!nextOpen && !loading) onClose(); + }} > -
-
+
-
- {title} -
-
{description}
+ {title} + + {description} +
@@ -235,9 +228,8 @@ export function GitDiscardConfirmModal(props: { {title}
-
-
, - document.body, + + ); } @@ -252,84 +244,79 @@ export function GitBranchFromCommitModal(props: { }) { const { target, branchName, loading, error, onBranchNameChange, onClose, onSubmit } = props; const { t } = useLocale(); - const titleId = useId(); const branchNameId = useId(); if (!target) return null; - return createPortal( -
{ + if (!nextOpen && !loading) onClose(); + }} > -
-
{ - event.preventDefault(); - onSubmit(); - }} - > -
-
- {t("projectTools.gitReview.createBranchFromCommitTitle")} -
-
- {t("projectTools.gitReview.createBranchFromCommitDescription") - .replace("{sha}", target.shortSha) - .replace("{subject}", target.subject || target.shortSha)} + + { + event.preventDefault(); + onSubmit(); + }} + > +
+ + {t("projectTools.gitReview.createBranchFromCommitTitle")} + + + {t("projectTools.gitReview.createBranchFromCommitDescription") + .replace("{sha}", target.shortSha) + .replace("{subject}", target.subject || target.shortSha)} +
-
-
-
-
- {target.shortSha} +
+
+
+ {target.shortSha} +
+
+ {target.subject || target.commitSha} +
-
- {target.subject || target.commitSha} +
+ + onBranchNameChange(event.target.value)} + className="h-9 text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-[calc(11px*var(--zone-font-scale,1))]" + placeholder={t("projectTools.gitReview.branchNamePlaceholder")} + autoFocus + disabled={loading} + />
+ {error ? ( +
+ {error} +
+ ) : null}
-
- - onBranchNameChange(event.target.value)} - className="h-9 text-[calc(11px*var(--zone-font-scale,1))] placeholder:text-[calc(11px*var(--zone-font-scale,1))]" - placeholder={t("projectTools.gitReview.branchNamePlaceholder")} - autoFocus - disabled={loading} - /> +
+ +
- {error ? ( -
- {error} -
- ) : null} -
-
- - -
- -
, - document.body, + + + ); } @@ -406,36 +393,30 @@ export function GitBranchSwitchConflictModal(props: { }) { const { conflict, loading, onClose, onConfirm } = props; const { t } = useLocale(); - const titleId = useId(); - if (!conflict) return null; - return createPortal( -
{ + if (!nextOpen && !loading) onClose(); + }} > -
-
+
-
+ {t("projectTools.gitReview.switchBranchConflictTitle")} -
-
+ + {t("projectTools.gitReview.switchBranchConflictDescription").replace( "{branch}", conflict.branch, )} -
+
@@ -451,9 +432,8 @@ export function GitBranchSwitchConflictModal(props: { {t("projectTools.gitReview.stashAndSwitch")}
-
-
, - document.body, + + ); } diff --git a/crates/agent-ui/src/components/ui/alert-dialog.tsx b/crates/agent-ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..9d7b80a0b --- /dev/null +++ b/crates/agent-ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,86 @@ +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; + +export const AlertDialog = AlertDialogPrimitive.Root; +export const AlertDialogPortal = AlertDialogPrimitive.Portal; +export const AlertDialogClose = AlertDialogPrimitive.Close; + +export const AlertDialogOverlay = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = "AlertDialogOverlay"; + +type AlertDialogContentProps = React.ComponentPropsWithoutRef & { + overlayClassName?: string; + portalProps?: React.ComponentPropsWithoutRef; + viewportClassName?: string; +}; + +export const AlertDialogContent = React.forwardRef( + ({ className, children, overlayClassName, portalProps, viewportClassName, ...props }, ref) => ( + + + + + {children} + + + + ), +); +AlertDialogContent.displayName = "AlertDialogContent"; + +export const AlertDialogTitle = React.forwardRef< + HTMLHeadingElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = "AlertDialogTitle"; + +export const AlertDialogDescription = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = "AlertDialogDescription"; + +export { AlertDialogPrimitive }; diff --git a/crates/agent-ui/src/components/ui/confirm-dialog.tsx b/crates/agent-ui/src/components/ui/confirm-dialog.tsx index 90a0c9aa1..2484289fd 100644 --- a/crates/agent-ui/src/components/ui/confirm-dialog.tsx +++ b/crates/agent-ui/src/components/ui/confirm-dialog.tsx @@ -1,6 +1,12 @@ -import { AlertDialog } from "@base-ui/react/alert-dialog"; import { AlertTriangle, X } from "@liveagent/ui/components/IconSet"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from "./alert-dialog"; import { Button } from "./button"; type ConfirmDialogTone = "warning" | "destructive"; @@ -60,100 +66,91 @@ function ConfirmDialog( const toneClasses = toneClassNames[tone]; return ( - { if (!open) onCancel(); }} > - - - - -
-
-
- -
-
- - {title} - - {subtitle ? ( -
- {subtitle} -
- ) : null} + +
+
+
+ +
+
+ + {title} + + {subtitle ? ( +
+ {subtitle}
-
- - - } - > - - + ) : null}
+
- {description || detail ? ( - }> - {description ? ( -
- {description} -
- ) : null} - {detail ? ( -
- {detail} -
- ) : null} -
- ) : null} - -
- {hideCancel ? null : ( - - } - > - {cancelLabel} - - )} + - {confirmLabel} - -
- - - - + variant="ghost" + size="icon" + className="h-8 w-8 shrink-0 rounded-xl text-muted-foreground hover:text-foreground" + title={closeLabel} + aria-label={closeLabel} + /> + } + > + + +
+ + {description || detail ? ( + }> + {description ? ( +
+ {description} +
+ ) : null} + {detail ? ( +
+ {detail} +
+ ) : null} +
+ ) : null} + +
+ {hideCancel ? null : ( + + } + > + {cancelLabel} + + )} + +
+ + ); } diff --git a/crates/agent-ui/src/components/ui/dialog.tsx b/crates/agent-ui/src/components/ui/dialog.tsx new file mode 100644 index 000000000..40f702f25 --- /dev/null +++ b/crates/agent-ui/src/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; + +export const Dialog = DialogPrimitive.Root; +export const DialogPortal = DialogPrimitive.Portal; + +export function DialogTrigger( + props: React.ComponentPropsWithoutRef, +) { + return ; +} + +export function DialogClose(props: React.ComponentPropsWithoutRef) { + return ; +} + +export const DialogOverlay = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = "DialogOverlay"; + +type DialogContentProps = React.ComponentPropsWithoutRef & { + overlayClassName?: string; + portalProps?: React.ComponentPropsWithoutRef; + viewportClassName?: string; +}; + +export const DialogContent = React.forwardRef( + ({ className, children, overlayClassName, portalProps, viewportClassName, ...props }, ref) => ( + + + + + {children} + + + + ), +); +DialogContent.displayName = "DialogContent"; + +export const DialogHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +DialogHeader.displayName = "DialogHeader"; + +export const DialogFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +DialogFooter.displayName = "DialogFooter"; + +export const DialogTitle = React.forwardRef< + HTMLHeadingElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = "DialogTitle"; + +export const DialogDescription = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = "DialogDescription"; + +export { DialogPrimitive }; diff --git a/crates/agent-ui/src/components/ui/sheet.tsx b/crates/agent-ui/src/components/ui/sheet.tsx index ee537bfdb..6697d9220 100644 --- a/crates/agent-ui/src/components/ui/sheet.tsx +++ b/crates/agent-ui/src/components/ui/sheet.tsx @@ -24,7 +24,7 @@ export const SheetBackdrop = React.forwardRef< ref={ref} data-slot="sheet-backdrop" className={cn( - "fixed inset-0 z-[100] bg-black/35 backdrop-blur-[2px] transition-opacity duration-200 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0", + "fixed inset-0 z-[100] bg-black/35 backdrop-blur-[2px] transition-opacity duration-200 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none", className, )} {...props} @@ -46,7 +46,7 @@ export const SheetViewport = React.forwardRef & { + backdropClassName?: string; closeLabel?: string; closeProps?: React.ComponentPropsWithoutRef; portalProps?: React.ComponentPropsWithoutRef; @@ -74,6 +75,7 @@ export const SheetPopup = React.forwardRef( { side = "right", variant = "default", + backdropClassName, className, children, closeLabel = "Close", @@ -85,7 +87,7 @@ export const SheetPopup = React.forwardRef( ref, ) => ( - + - {pendingDialog ? ( -
-
-
{dialogTitle}
-
{dialogDescription}
-
- - - -
+ { + if (!open) setPendingDialog(null); + }} + > + + {dialogTitle} + + {dialogDescription} + +
+ + +
-
- ) : null} + +
); } diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceSftpPanel.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceSftpPanel.tsx index c1519e70d..770c7304e 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceSftpPanel.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceSftpPanel.tsx @@ -14,12 +14,17 @@ import { Upload, } from "@liveagent/ui/components/IconSet"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@liveagent/ui/components/ui/dialog"; import { useLocale } from "@liveagent/ui/i18n/index"; import type { SftpClient, SftpEntry, SftpSide, SftpTransfer } from "@liveagent/ui/lib/sftp/types"; import { cn } from "@liveagent/ui/lib/shared/utils"; import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; type WorkspaceSftpPanelProps = { session: TerminalSession; @@ -1982,83 +1987,62 @@ function CreateFolderDialog(props: { } = props; const canSubmit = value.trim().length > 0 && !submitting; - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - onCancel(); - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onCancel]); - - return createPortal( -
- - -
- -
, - document.body, + return ( + !open && !submitting && onCancel()}> + +
{ + event.preventDefault(); + if (canSubmit) { + onSubmit(); + } + }} + > +
+ {title} + {path ? ( + + {path} + + ) : null} +
+
+ + onChange(event.currentTarget.value)} + /> +
+
+ + +
+
+
+
); } @@ -2071,35 +2055,12 @@ function CopyPathDialog(props: { }) { const { title, prompt, closeLabel, text, onClose } = props; - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - onClose(); - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - return createPortal( -
-