fix(client): handle Sidebar Browser navigation fallbacks
This commit is contained in:
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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
## 系统提示词行
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user