Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions apps/desktop/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -478,7 +478,7 @@ function InlineCode({
onClick={() =>
target.kind === "file"
? openFileRef(text ?? target.path, baseDir)
: openUrl(target.url)
: openHttpUrl(target.url)
}
>
<code className={className} {...rest}>
Expand All @@ -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<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -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<HTMLAnchorElement>) => {
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);
Expand Down Expand Up @@ -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 : "";
Expand All @@ -701,7 +695,7 @@ function MarkdownImage({
alt={alt ?? ""}
className="chat-image-remote"
title={urlTitle}
onClick={() => openUrl(source)}
onClick={() => openHttpUrl(source)}
/>
);
}
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/features/plugins/PluginDetailSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
IconX,
} from "../../components/icons";
import { Markdown } from "../../components/Markdown";
import { openHttpUrl } from "../../lib/open-http-url";
import {
formatBytes,
formatDate,
Expand Down Expand Up @@ -37,7 +38,6 @@ export function PluginDetailSheet({
installedDetail,
busyId,
queueInstall,
openUrlInWorkPanel,
setSelectedVersion,
}: PluginsPageModel) {
return (
Expand Down Expand Up @@ -173,7 +173,7 @@ export function PluginDetailSheet({
key={link.key}
type="button"
className="plugins-sheet-link"
onClick={() => openUrlInWorkPanel(link.url)}
onClick={() => openHttpUrl(link.url)}
>
<IconLink size={13} />
<span className="plugins-sheet-link-label">
Expand All @@ -196,7 +196,7 @@ export function PluginDetailSheet({
type="button"
className="plugins-sheet-link"
onClick={() =>
openUrlInWorkPanel(activeVersion.provenance!.sourceRepository)
openHttpUrl(activeVersion.provenance!.sourceRepository)
}
>
<IconLink size={13} />
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/features/plugins/usePluginsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -662,7 +661,6 @@ export function usePluginsPage() {
settings,
refreshPlugins,
showToast,
openUrlInWorkPanel,
activateProject,
currentProjectPath,
tab,
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/hooks/use-preview-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
);
}

Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/lib/open-http-url.ts
Original file line number Diff line number Diff line change
@@ -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);
}
73 changes: 73 additions & 0 deletions apps/desktop/test/open-http-url.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
1 change: 1 addition & 0 deletions apps/desktop/test/tool-row-file-refs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
6 changes: 5 additions & 1 deletion docs/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/zh-CN/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@
- **默认项**卡:主机支持的默认运行模式(Agent / Plan / Goal)、
命令 Shell 选择、链接打开目标、上下文用量显示(剩余或已用)、
回车发送控制和大段文本粘贴阈值。链接打开目标默认使用工作面板浏览器,
可将纯 HTTP(S) 链接点击路由到系统浏览器。上下文用量显示控制输入框
可将对话、会话记录和插件页的 HTTP(S) 点击路由到系统浏览器。插件/设置页
若目标是工作面板,会先回到对话再打开,避免被遮罩挡住;没有会话时才回退到
系统浏览器。工作区 HTML
预览、BrowserPreview、OAuth 和问题反馈仍走原有目标。上下文用量显示控制输入框
工具栏上下文环及其弹层是以剩余容量还是已用容量为引导数值;默认为剩余。
该阈值决定纯文本粘贴何时转为会话临时文件,默认值为 600 个字符,
接受 1 至 1,000,000 的整数。
Expand Down