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 的整数。