Răsfoiți Sursa

fix(client): handle Sidebar Browser navigation fallbacks

imccyu 3 săptămâni în urmă
părinte
comite
80f90e61d3

+ 2 - 2
.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

+ 1 - 1
.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.
 

+ 1 - 1
.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 负责。
 

+ 2 - 2
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

+ 1 - 1
packages/client/ui-chat/README.md

@@ -29,7 +29,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 <a id="reference-previews"></a>
 ## 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.
 
 <a id="system-prompt-row"></a>
 ## System prompt row

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 <a id="reference-previews"></a>
 ## 引用预览
 
-Assistant Markdown 中的 HTTP(S) 链接在普通点击时会打开新的右侧 Sidebar Browser tab;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
+Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
 
 <a id="system-prompt-row"></a>
 ## 系统提示词行

+ 5 - 1
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),

+ 22 - 1
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<ClientRemote['session']['openWorkspacePath']>(
     () => 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)

+ 2 - 3
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' }

+ 1 - 1
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(() => {

+ 8 - 0
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')

+ 8 - 0
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 })