diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml index 1e523415f2..b261e98e5b 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md -2026-09-16-sidebar-browser.md: 2e1add9d9e017e348a9a702e5e0d01c0380bb08f -2026-09-16-sidebar-browser.zh.md: 467286322c714839490c6e8f81405137f2d02249 +2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c +2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md index 2e1add9d9e..3fa4e1dbb7 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md @@ -14,7 +14,7 @@ A parent page cannot inspect or drive a cross-origin iframe's internal history. `@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values. -`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters; the Markdown renderer does not import the Browser feature. +`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters when that type is registered, or uses the system browser otherwise; the Markdown renderer does not import the Browser feature. The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface. diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md index 467286322c..69d78dbf2b 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md @@ -14,7 +14,7 @@ Status: implemented `@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。 -`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider,并以 URL 作为 typed navigation 参数打开新的 `browser` tab;Markdown renderer 不导入 Browser feature。 +`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider;已注册该类型时,它以 URL 作为 typed navigation 参数打开新的 `browser` tab,否则使用系统浏览器;Markdown renderer 不导入 Browser feature。 地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5923886806..780e0b76ad 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 147c43339fab245701d34d499f9d4434cb0216c5 -README.zh.md: aabb443a08964211d05052916ae4f70e875820b2 +README.md: b5314413df8195834152c354df89c25600afc63b +README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 147c43339f..b5314413df 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -29,7 +29,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index aabb443a08..23288907f6 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ## 引用预览 -Assistant Markdown 中的 HTTP(S) 链接在普通点击时会打开新的右侧 Sidebar Browser tab;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## 系统提示词行 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index a31e581c76..c9822804ab 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -144,7 +144,11 @@ export function apply(ctx: Context): void { ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) }, openExternalLink: (url) => { - ctx.sidebarRight.openTab('browser', { params: { url } }) + if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { + ctx.sidebarRight.openTab('browser', { params: { url } }) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } }, loadOlder: () => { void session.loadOlder() }, loadThrough: seq => session.loadThrough(seq), diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 76be299d0c..5c71d37729 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -56,6 +56,10 @@ async function bench() { openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + const sidebarRightTabs = { + get: vi.fn<(kind: string) => object | undefined>(() => ({})), + } + runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), ) @@ -96,7 +100,9 @@ async function bench() { ) => ChatViewInjected)(id, instance.actions) return { instance, injected } } - return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi, rootReference, openSession } + return { + runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, + } } describe('Chat inject API', () => { @@ -160,6 +166,21 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) + it('opens message HTTP(S) links in the system browser when no Sidebar Browser is registered', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + b.sidebarRightTabs.get.mockReturnValue(undefined) + const { injected } = b.chatViewApi(b.rootReference) + injected.openExternalLink('https://example.test/path') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + expect(open).toHaveBeenCalledWith('https://example.test/path', '_blank', 'noopener,noreferrer') + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-sidebar-browser/src/client/browser/url.ts b/packages/client/ui-sidebar-browser/src/client/browser/url.ts index a882fa68b2..3ced712338 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/url.ts +++ b/packages/client/ui-sidebar-browser/src/client/browser/url.ts @@ -26,9 +26,8 @@ export function parseBrowserAddress(input: string, applicationOrigin?: string): const trimmed = input.trim() if (trimmed === '') return { ok: false, reason: 'empty' } if (trimmed.length > MAX_BROWSER_URL_LENGTH) return { ok: false, reason: 'invalid' } - const explicitScheme = /^[A-Za-z][A-Za-z\d+.-]*:\/\//u.test(trimmed) - const nonHierarchicalScheme = /^(?:about|blob|data|javascript|mailto|tel|view-source):/iu.test(trimmed) - const candidate = explicitScheme || nonHierarchicalScheme ? trimmed : `https://${trimmed}` + const explicitScheme = /^[A-Za-z][A-Za-z\d+.-]*:(?!\d+(?:[/?#]|$))/u.test(trimmed) + const candidate = explicitScheme ? trimmed : `https://${trimmed}` let url: URL try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } } if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' } diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx index 022104e4b7..b5fb7d88dd 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx @@ -65,7 +65,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode { const initialState = useRef(state) const initialUrl = useRef(tab.navigation.params?.url) const current = BrowserNavigation.current(state) - const [draft, setDraft] = useBrowserDraft(current?.url, state.request?.revision) + const [draft, setDraft] = useBrowserDraft(current?.url ?? initialUrl.current, state.request?.revision) const [mountCount, setMountCount] = useState(0) useEffect(() => { diff --git a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx index 514974cd92..4615a0a0d4 100644 --- a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx +++ b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx @@ -205,6 +205,14 @@ describe('BrowserBody', () => { expect(open).toHaveBeenCalledWith('https://initial.example/path', '_blank', 'noopener,noreferrer') }) + it('keeps a rejected initial URL in the address input for editing', async () => { + const mounted = mountBrowser({ url: 'file:/work/index.html' }) + const input = mounted.view.getByRole('textbox') + await waitFor(() => { expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.protocol']) }) + expect(input).toHaveProperty('value', 'file:/work/index.html') + expect(mounted.view.container.querySelector('iframe')).toBeNull() + }) + it('reloads the latest controlled URL instead of replaying the initial URL after remount', async () => { const mounted = mountBrowser({ url: 'https://initial.example/path' }) const input = mounted.view.getByRole('textbox') diff --git a/packages/client/ui-sidebar-browser/tests/url.client.spec.ts b/packages/client/ui-sidebar-browser/tests/url.client.spec.ts index 7e5e5f31bb..08a6239990 100644 --- a/packages/client/ui-sidebar-browser/tests/url.client.spec.ts +++ b/packages/client/ui-sidebar-browser/tests/url.client.spec.ts @@ -26,6 +26,12 @@ describe('Browser address policy', () => { ok: true, target: { kind: 'http', url: 'http://example.com/', title: 'example.com' }, }) expect(parseBrowserAddress('http://128.0.0.1/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } }) + expect(parseBrowserAddress('http:/example.com/path', APP)).toEqual({ + ok: true, target: { kind: 'http', url: 'http://example.com/path', title: 'example.com' }, + }) + expect(parseBrowserAddress('https:/example.com/path', APP)).toEqual({ + ok: true, target: { kind: 'https', url: 'https://example.com/path', title: 'example.com' }, + }) }) it('rejects every undeclared or privileged form', () => { @@ -36,6 +42,8 @@ describe('Browser address policy', () => { expect(parseBrowserAddress(`https://${'a'.repeat(17_000)}.example`, APP)).toEqual({ ok: false, reason: 'invalid' }) expect(parseBrowserAddress('file:///work/index.html', APP)).toEqual({ ok: false, reason: 'protocol' }) expect(parseBrowserAddress('file:////server/share/index.html', APP)).toEqual({ ok: false, reason: 'protocol' }) + expect(parseBrowserAddress('file:/work/index.html', APP)).toEqual({ ok: false, reason: 'protocol' }) + expect(parseBrowserAddress('ftp:/example.com/file', APP)).toEqual({ ok: false, reason: 'protocol' }) expect(parseBrowserAddress(':::', APP)).toEqual({ ok: false, reason: 'invalid' }) expect(parseBrowserAddress('https://example.test', 'not an origin')).toMatchObject({ ok: true }) expect(parseBrowserAddress('https://example.test')).toMatchObject({ ok: true })