From 499325933b730ce6174aeea4aae500cc1bc11c3d Mon Sep 17 00:00:00 2001 From: LectWolf <67421358+LectWolf@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:26:41 +0800 Subject: [PATCH] fix(desktop): honor link-open destination for chat and plugin HTTP links Chat markdown, transcript previews, and plugin homepage/repo buttons each opened HTTP URLs their own way, so the setting only partly applied and plugin links vanished behind the detail overlay. Share one opener that follows Link open destination, returns to chat when the work panel would be covered, and falls back to the OS browser without a session. --- apps/desktop/src/components/Markdown.tsx | 18 ++--- .../features/plugins/PluginDetailSheet.tsx | 6 +- .../src/features/plugins/usePluginsPage.ts | 2 - apps/desktop/src/hooks/use-preview-target.ts | 11 +-- apps/desktop/src/lib/open-http-url.ts | 43 +++++++++++ apps/desktop/test/open-http-url.test.mjs | 73 +++++++++++++++++++ apps/desktop/test/tool-row-file-refs.test.mjs | 1 + docs/spec/04-ux/06-settings-ia.md | 6 +- docs/spec/04-ux/08-component-spec.md | 12 +-- docs/spec/06-delivery/04-e2e-test-plan.md | 15 +++- docs/zh-CN/spec/04-ux/06-settings-ia.md | 5 +- 11 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/lib/open-http-url.ts create mode 100644 apps/desktop/test/open-http-url.test.mjs diff --git a/apps/desktop/src/components/Markdown.tsx b/apps/desktop/src/components/Markdown.tsx index 02b837c09..b14b0d877 100644 --- a/apps/desktop/src/components/Markdown.tsx +++ b/apps/desktop/src/components/Markdown.tsx @@ -39,6 +39,7 @@ import { import { TooltipButton } from "./ui"; import { createPortal } from "react-dom"; import { api } from "../lib/api"; +import { openHttpUrl } from "../lib/open-http-url"; import { rehypeSourcePositions, sourcePositionProps, @@ -454,7 +455,6 @@ function InlineCode({ }: ComponentProps<"code"> & { node?: unknown }) { const root = useAppStore((s) => s.workspace?.path); const baseDir = useContext(MarkdownBaseDirContext); - const openUrl = useAppStore((s) => s.openUrlInWorkPanel); const openFileRef = useOpenChatFileRef(); const text = typeof children === "string" ? children : null; const target = @@ -478,7 +478,7 @@ function InlineCode({ onClick={() => target.kind === "file" ? openFileRef(text ?? target.path, baseDir) - : openUrl(target.url) + : openHttpUrl(target.url) } > @@ -500,7 +500,6 @@ function Anchor({ const openFileRef = useOpenChatFileRef(); const openUrl = useAppStore((s) => s.openUrlInWorkPanel); const showToast = useAppStore((s) => s.showToast); - const linkOpenTarget = useAppStore((s) => s.settings?.linkOpenTarget ?? "workpanel"); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const menuRef = useRef(null); @@ -580,18 +579,14 @@ function Anchor({ } }; - // Plain click previews in the work panel (or external browser based on setting). - // Modified clicks fall through to _blank, which main routes to shell.openExternal. + // Plain click follows Link open destination. Modifier clicks fall through + // to _blank, which main routes to shell.openExternal. const onClick = (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (!href) return; if (/^https?:\/\//i.test(href)) { e.preventDefault(); - if (linkOpenTarget === "external") { - void api.browserOpenExternal(href); - } else { - openUrl(href); - } + openHttpUrl(href); return; } const rel = toWorkspaceRel(safeDecodeUri(href), root, baseDir); @@ -678,7 +673,6 @@ function MarkdownImage({ const root = useAppStore((s) => s.workspace?.path); const baseDir = useContext(MarkdownBaseDirContext); const openFileRef = useOpenChatFileRef(); - const openUrl = useAppStore((s) => s.openUrlInWorkPanel); const fileTitle = usePreviewTitle("file"); const urlTitle = usePreviewTitle("url"); const source = typeof src === "string" ? src : ""; @@ -701,7 +695,7 @@ function MarkdownImage({ alt={alt ?? ""} className="chat-image-remote" title={urlTitle} - onClick={() => openUrl(source)} + onClick={() => openHttpUrl(source)} /> ); } diff --git a/apps/desktop/src/features/plugins/PluginDetailSheet.tsx b/apps/desktop/src/features/plugins/PluginDetailSheet.tsx index 2b418be0d..90e62798d 100644 --- a/apps/desktop/src/features/plugins/PluginDetailSheet.tsx +++ b/apps/desktop/src/features/plugins/PluginDetailSheet.tsx @@ -7,6 +7,7 @@ import { IconX, } from "../../components/icons"; import { Markdown } from "../../components/Markdown"; +import { openHttpUrl } from "../../lib/open-http-url"; import { formatBytes, formatDate, @@ -37,7 +38,6 @@ export function PluginDetailSheet({ installedDetail, busyId, queueInstall, - openUrlInWorkPanel, setSelectedVersion, }: PluginsPageModel) { return ( @@ -173,7 +173,7 @@ export function PluginDetailSheet({ key={link.key} type="button" className="plugins-sheet-link" - onClick={() => openUrlInWorkPanel(link.url)} + onClick={() => openHttpUrl(link.url)} > @@ -196,7 +196,7 @@ export function PluginDetailSheet({ type="button" className="plugins-sheet-link" onClick={() => - openUrlInWorkPanel(activeVersion.provenance!.sourceRepository) + openHttpUrl(activeVersion.provenance!.sourceRepository) } > diff --git a/apps/desktop/src/features/plugins/usePluginsPage.ts b/apps/desktop/src/features/plugins/usePluginsPage.ts index 63ddcaf45..ca01d66a0 100644 --- a/apps/desktop/src/features/plugins/usePluginsPage.ts +++ b/apps/desktop/src/features/plugins/usePluginsPage.ts @@ -43,7 +43,6 @@ export function usePluginsPage() { const settings = useAppStore((s) => s.settings); const refreshPlugins = useAppStore((s) => s.refreshPlugins); const showToast = useAppStore((s) => s.showToast); - const openUrlInWorkPanel = useAppStore((s) => s.openUrlInWorkPanel); const activateProject = useAppStore((s) => s.activateProject); /** * The folder open in this window. Scoping something to "this project" is only @@ -662,7 +661,6 @@ export function usePluginsPage() { settings, refreshPlugins, showToast, - openUrlInWorkPanel, activateProject, currentProjectPath, tab, diff --git a/apps/desktop/src/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 70684a8eb..16ea456f5 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -3,10 +3,11 @@ import { useTranslation } from "react-i18next"; import { useAppStore } from "../stores/app-store"; import { api } from "../lib/api"; import { isHtmlFilePath, toWorkspaceRel, type ChatPreviewTarget } from "../lib/chat-links"; +import { openHttpUrl } from "../lib/open-http-url"; import { FILE_MANAGER_PLUGIN_TAB, fileManagerPluginTab } from "../lib/work-panel-tabs"; /** - * Open one target the transcript named, in the work panel. + * Open one target the transcript named. * * A file never opens its own path directly. It goes through the same * completion the message body uses (`useOpenChatFileRef`, ADR 0262), so the @@ -15,15 +16,15 @@ import { FILE_MANAGER_PLUGIN_TAB, fileManagerPluginTab } from "../lib/work-panel * a project file exactly like a chat chip, and fall back the same way when the * view, the file, or the reference is not there. One opener for the whole * transcript is also what keeps a shorthand honest — a click opens the file - * that matched, or reports that nothing did. URLs keep the embedded browser. + * that matched, or reports that nothing did. HTTP(S) URLs follow the Link + * open destination setting. */ export function useOpenPreviewTarget() { const openFileRef = useOpenChatFileRef(); - const openUrl = useAppStore((s) => s.openUrlInWorkPanel); return useCallback( (target: ChatPreviewTarget) => - target.kind === "file" ? openFileRef(target.path) : openUrl(target.url), - [openFileRef, openUrl], + target.kind === "file" ? openFileRef(target.path) : openHttpUrl(target.url), + [openFileRef], ); } diff --git a/apps/desktop/src/lib/open-http-url.ts b/apps/desktop/src/lib/open-http-url.ts new file mode 100644 index 000000000..3c54cf2b6 --- /dev/null +++ b/apps/desktop/src/lib/open-http-url.ts @@ -0,0 +1,43 @@ +import { api } from "./api"; +import { useAppStore } from "../stores/app-store"; + +export type ResolvedLinkOpenTarget = "workpanel" | "external"; + +/** Persistable setting → destination. Absent or unknown values keep the work panel. */ +export function resolveLinkOpenTarget( + linkOpenTarget: string | null | undefined, +): ResolvedLinkOpenTarget { + return linkOpenTarget === "external" ? "external" : "workpanel"; +} + +/** Work-panel tabs are per-session; without one the dock cannot open. */ +export function canPresentWorkPanelBrowser(state: { + activeSessionId?: string | null; +}): boolean { + return Boolean(state.activeSessionId); +} + +/** + * Open an HTTP(S) URL using Settings → AI → Link open destination. + * + * Explicit preview (workspace HTML, BrowserPreview, the link context-menu + * "Open in work panel" item) keeps calling `openUrlInWorkPanel` directly. + * + * Plugin/settings pages cover or unmount the dock, so a work-panel destination + * returns to chat first. A missing session falls back to the OS browser. + */ +export function openHttpUrl(url: string): void { + const trimmed = url.trim(); + if (!/^https?:\/\//i.test(trimmed)) return; + const state = useAppStore.getState(); + const wantsWorkPanel = + resolveLinkOpenTarget(state.settings?.linkOpenTarget) === "workpanel"; + if (wantsWorkPanel && canPresentWorkPanelBrowser(state)) { + if (state.page !== "chat") { + state.setPage("chat"); + } + state.openUrlInWorkPanel(trimmed); + return; + } + void api.browserOpenExternal(trimmed); +} diff --git a/apps/desktop/test/open-http-url.test.mjs b/apps/desktop/test/open-http-url.test.mjs new file mode 100644 index 000000000..820c5677d --- /dev/null +++ b/apps/desktop/test/open-http-url.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const helperSource = await readFile( + new URL("../src/lib/open-http-url.ts", import.meta.url), + "utf8", +); +const markdownSource = await readFile( + new URL("../src/components/Markdown.tsx", import.meta.url), + "utf8", +); +const previewSource = await readFile( + new URL("../src/hooks/use-preview-target.ts", import.meta.url), + "utf8", +); +const pluginSheetSource = await readFile( + new URL("../src/features/plugins/PluginDetailSheet.tsx", import.meta.url), + "utf8", +); +const workPanelSource = await readFile( + new URL("../src/stores/slices/work-panel-slice.ts", import.meta.url), + "utf8", +); + +test("resolveLinkOpenTarget treats only external as the OS browser", () => { + assert.match( + helperSource, + /return linkOpenTarget === "external" \? "external" : "workpanel";/, + ); +}); + +test("work-panel destination returns to chat when a session can show it", () => { + assert.match(helperSource, /return Boolean\(state.activeSessionId\);/); + assert.match(helperSource, /if \(state.page !== "chat"\) \{/); + assert.match(helperSource, /state.setPage\("chat"\);/); + assert.match( + helperSource, + /wantsWorkPanel && canPresentWorkPanelBrowser\(state\)/, + ); + assert.match(helperSource, /state.openUrlInWorkPanel\(trimmed\);/); + assert.match(helperSource, /void api.browserOpenExternal\(trimmed\);/); +}); + +test("chat markdown HTTP clicks share openHttpUrl", () => { + assert.match(markdownSource, /import \{ openHttpUrl \} from "\.\.\/lib\/open-http-url"/); + assert.match(markdownSource, /openHttpUrl\(href\)/); + assert.match(markdownSource, /openHttpUrl\(target\.url\)/); + assert.match(markdownSource, /openHttpUrl\(source\)/); + assert.doesNotMatch( + markdownSource, + /if \(linkOpenTarget === "external"\) \{\s*void api\.browserOpenExternal\(href\);/, + ); +}); + +test("previewable transcript URLs follow the link-open setting", () => { + assert.match(previewSource, /import \{ openHttpUrl \} from "\.\.\/lib\/open-http-url"/); + assert.match( + previewSource, + /target\.kind === "file" \? openFileRef\(target\.path\) : openHttpUrl\(target\.url\)/, + ); +}); + +test("plugin homepage and repository links follow the link-open setting", () => { + assert.match(pluginSheetSource, /import \{ openHttpUrl \} from "\.\.\/\.\.\/lib\/open-http-url"/); + assert.match(pluginSheetSource, /openHttpUrl\(link\.url\)/); + assert.match(pluginSheetSource, /openHttpUrl\(activeVersion\.provenance!\.sourceRepository\)/); + assert.doesNotMatch(pluginSheetSource, /openUrlInWorkPanel\(/); +}); + +test("forced work-panel preview does not read linkOpenTarget", () => { + assert.doesNotMatch(workPanelSource, /linkOpenTarget/); +}); diff --git a/apps/desktop/test/tool-row-file-refs.test.mjs b/apps/desktop/test/tool-row-file-refs.test.mjs index c254ce538..4da37f78f 100644 --- a/apps/desktop/test/tool-row-file-refs.test.mjs +++ b/apps/desktop/test/tool-row-file-refs.test.mjs @@ -73,6 +73,7 @@ const { useOpenPreviewTarget } = loadModule("../src/hooks/use-preview-target.ts" }, }, "../lib/chat-links": loadModule("../src/lib/chat-links.ts", {}), + "../lib/open-http-url": { openHttpUrl: (...args) => calls.urls.push(args) }, "../lib/work-panel-tabs": workPanelTabs, }); diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 42dee07b8..f2125c25a 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -141,7 +141,11 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome ( command shell selection, Link open destination, context usage display (remaining or used), thinking display mode, Enter-to-send control, and the large text paste threshold. Link open destination uses the Work panel browser by default - and can route plain HTTP(S) link clicks to the system browser. Context + and routes chat, transcript, and plugin HTTP(S) clicks to the system + browser when set to Default OS browser. Plugin/settings clicks that want + the work panel return to chat first so the dock is visible; a missing + session falls back to the OS browser. Workspace HTML preview, + BrowserPreview, OAuth, and Feedback keep their existing destinations. Context usage display controls whether the composer toolbar context ring and its popover lead with the remaining or the used capacity figure; the default is remaining. The threshold controls when a text-only paste becomes a diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 8b186e93c..59d6bbf60 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1589,11 +1589,13 @@ Single message render — either user (plaintext) or assistant (markdown streami image thumbnail resolves and opens the same way. A chip whose reference matches nothing opens nothing and reports itself; the OS default application is no longer what this click does. - HTTP(S) URLs remain inline text links. Plain clicks follow the persisted - Link open destination setting (Work panel browser by default, or the system - default browser). Right-clicking a link opens a body-level context menu with - Open in default browser, Open in work panel, and Copy link address. Modifier - clicks (Ctrl/Cmd/Shift/Alt) continue to open externally. Long URL links wrap + HTTP(S) URLs remain inline text links. Plain clicks — including markdown + links, autolinked URLs, inline-code URLs, and remote images — follow the + persisted Link open destination setting (Work panel browser by default, or + the system default browser). Right-clicking a link opens a body-level + context menu with Open in default browser, Open in work panel, and Copy + link address. Modifier clicks (Ctrl/Cmd/Shift/Alt) continue to open + externally. Long URL links wrap within the plate and keep logical-start alignment instead of inheriting the browser's centered button text. - Assistant: transparent surface, left-aligned, markdown rendered at full diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e85f0133f..887f3aa8a 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10811,20 +10811,27 @@ are withdrawn with ADR 0165. 4. Repeat a link click with Ctrl/Cmd, Shift, and Alt held. - **Expected**: - The Work panel browser is the default plain-click destination. - - The Default OS browser setting routes plain HTTP(S) clicks through the - main-owned external opener; changing the setting persists after reload. + - The Default OS browser setting routes chat, transcript, and plugin HTTP(S) + clicks through the main-owned external opener, including markdown links, + autolinked URLs, and inline-code URLs in assistant replies; changing the + setting persists after reload. - The body-level context menu remains interactive when clicked. Its external and work-panel actions open the requested destination, and Copy link address updates the clipboard before showing the success toast. A rejected clipboard write shows an error toast instead of a success toast. - Modifier clicks continue to open links externally regardless of the setting. + - Plugin/settings clicks that want the work panel return to chat so the + dock is visible. A missing session falls back to the OS browser. + - Workspace HTML preview, BrowserPreview, OAuth, and Feedback keep their + existing destinations. - **Specs linked**: `04-ux/06-settings-ia.md`, `04-ux/08-component-spec.md` §8.3, `03-runtime/01-ipc-protocol.md`, `08-meta/decisions-log.md` (D330) - **Acceptance**: B (settings), C (conversation & stream), Security, Quality - **Milestone**: M5 -- **Status**: Unit-covered (`apps/desktop/test/markdown-link-menu.test.mjs` and - locale catalog tests); full UI journey Draft (run only in a capable environment when this surface changes) +- **Status**: Unit-covered (`apps/desktop/test/markdown-link-menu.test.mjs`, + locale catalog tests, `apps/desktop/test/open-http-url.test.mjs`); full UI + journey Draft (run only in a capable environment when this surface changes) #### E2E-201: Alias a configured model and copy a model id diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index 998ce4874..87e59fc92 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -82,7 +82,10 @@ - **默认项**卡:主机支持的默认运行模式(Agent / Plan / Goal)、 命令 Shell 选择、链接打开目标、上下文用量显示(剩余或已用)、 回车发送控制和大段文本粘贴阈值。链接打开目标默认使用工作面板浏览器, - 可将纯 HTTP(S) 链接点击路由到系统浏览器。上下文用量显示控制输入框 + 可将对话、会话记录和插件页的 HTTP(S) 点击路由到系统浏览器。插件/设置页 + 若目标是工作面板,会先回到对话再打开,避免被遮罩挡住;没有会话时才回退到 + 系统浏览器。工作区 HTML + 预览、BrowserPreview、OAuth 和问题反馈仍走原有目标。上下文用量显示控制输入框 工具栏上下文环及其弹层是以剩余容量还是已用容量为引导数值;默认为剩余。 该阈值决定纯文本粘贴何时转为会话临时文件,默认值为 600 个字符, 接受 1 至 1,000,000 的整数。