fix(client): handle Sidebar Browser navigation fallbacks

This commit is contained in:
imccyu committed 2026-09-17 15:14:12 +08:00
1 parent 9637a4d051
commit 80f90e61d3
12 files changed
+54 -14

No files matched your search

@@ -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
@@ -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.
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
@@ -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)
@@ -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' }
@@ -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(() => {
@@ -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')
@@ -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 })