Merge remote-tracking branch 'origin/master' into codex/pr-4810-review-fixes

This commit is contained in:
Dudu-0223 committed 2026-09-22 15:40:58 +08:00
commit bfc772bb47
58 files changed
+424 -174

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+2
View File
@@ -119,6 +119,8 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+3 -3
View File
@@ -1,8 +1,8 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(32 32) scale(0.9375) translate(-40 -32)"><g filter="url(#filter0_d_1637_371)">
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="#FBFBFB"/>
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint0_linear_1637_371)" fill-opacity="0.4"/>
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint1_linear_1637_371)" fill-opacity="0.8"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="#FBFBFB"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint0_linear_1637_371)" fill-opacity="0.4"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint1_linear_1637_371)" fill-opacity="0.8"/>
<g filter="url(#filter1_ddi_1637_371)">
<path d="M968.002 326.025C959.5 321.85 955.842 329.804 950.865 333.835C949.162 335.143 947.722 336.834 946.283 338.393C933.859 351.673 919.336 360.407 900.376 359.363C872.649 357.804 848.963 366.525 828.035 387.759C823.586 361.583 808.812 345.951 786.313 335.936C774.536 330.729 762.64 325.51 754.401 314.186C748.645 306.112 747.074 297.127 744.195 288.274C742.36 282.936 740.538 277.465 734.385 276.554C727.718 275.51 725.103 281.112 722.476 285.803C712.006 304.95 707.952 326.039 708.349 347.405C709.26 395.463 729.54 433.742 769.823 460.962C774.404 464.094 775.579 467.212 774.14 471.771C771.394 481.153 768.119 490.257 765.241 499.639C763.406 505.624 760.66 506.933 754.256 504.329C732.154 495.08 713.049 481.417 696.175 464.874C667.524 437.138 641.633 406.536 609.325 382.566C601.733 376.964 594.154 371.758 586.298 366.802C553.33 334.773 590.616 308.464 599.251 305.333C608.282 302.082 602.393 290.877 573.214 291.009C544.048 291.141 517.364 300.906 483.353 313.935C478.389 315.891 473.147 317.318 467.786 318.494C436.917 312.64 404.873 311.332 371.377 315.111C308.332 322.141 257.975 351.964 220.953 402.876C176.471 464.081 166.014 533.624 178.835 606.153C192.302 682.594 231.291 745.887 291.194 795.372C353.328 846.68 424.876 871.813 506.498 866.99C556.076 864.122 611.279 857.489 673.532 804.74C689.23 812.55 705.708 815.681 733.052 818.02C754.111 819.976 774.391 816.976 790.076 813.726C814.674 808.519 812.971 785.726 804.072 781.564C731.995 747.962 747.826 761.638 733.435 750.565C770.06 707.198 825.263 662.139 846.85 516.156C848.553 504.567 847.114 497.273 846.85 487.892C846.718 482.157 848.025 479.95 854.574 479.303C872.623 477.215 890.156 472.273 906.238 463.42C952.938 437.892 971.765 395.965 976.215 345.7C976.875 338.01 976.083 330.069 967.976 326.039L968.002 326.025ZM561.067 778.432C491.222 723.477 457.343 705.374 443.347 706.154C430.263 706.933 432.626 721.918 435.505 731.682C438.515 741.315 442.436 747.962 447.929 756.418C451.718 762.021 454.346 770.345 444.14 776.609C421.641 790.549 382.533 771.918 380.698 771.006C335.174 744.182 297.109 708.757 270.293 660.316C244.389 613.698 229.35 563.685 226.868 510.302C226.208 497.406 230.01 492.847 242.831 490.508C259.704 487.376 277.106 486.729 293.98 489.2C365.264 499.625 425.959 531.523 476.844 582.052C505.878 610.831 527.861 645.213 550.491 678.815C574.561 714.492 600.452 748.49 633.42 776.358C645.066 786.122 654.348 793.548 663.247 799.019C636.431 802.018 591.698 802.666 561.08 778.445L561.067 778.432ZM594.55 562.905C594.55 557.171 599.132 552.625 604.888 552.625C606.195 552.625 607.371 552.876 608.414 553.273C609.853 553.801 611.16 554.581 612.203 555.744C614.038 557.567 615.081 560.17 615.081 562.905C615.081 568.64 610.5 573.185 604.743 573.185C598.987 573.185 594.537 568.627 594.537 562.905H594.55ZM698.539 616.301C691.871 619.037 685.19 621.375 678.787 621.64C668.845 622.155 657.992 618.125 652.103 613.17C642.953 605.479 636.404 601.185 633.658 587.773C632.483 582.039 633.13 573.185 634.186 568.111C636.536 557.171 633.922 550.141 626.212 543.759C619.927 538.553 611.952 537.112 603.185 537.112C599.911 537.112 596.914 535.685 594.682 534.509C591.025 532.686 588.015 528.127 590.893 522.525C591.804 520.701 596.254 516.275 597.297 515.495C609.206 508.716 622.937 510.936 635.625 516.01C647.403 520.833 656.288 529.686 669.109 542.186C682.193 557.289 684.543 561.465 692.003 572.789C697.892 581.642 703.252 590.759 706.909 601.185C709.128 607.699 706.249 613.038 698.539 616.288V616.301Z" fill="url(#paint2_linear_1637_371)" fill-opacity="0.9"/>
</g>

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

+6 -2
View File
@@ -3,6 +3,8 @@
export const en = {
application: 'Application',
aboutMenu: 'About DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'Version V{version}',
hideApplication: 'Hide DeepSeek Harness',
hideOtherApplications: 'Hide Others',
showAllApplications: 'Show All',
@@ -88,7 +90,7 @@ export const en = {
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
updateAvailable: 'An update is available',
updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.',
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.',
installAndRestart: 'Install and Restart',
later: 'Later',
updateFailedTitle: 'Update Failed',
@@ -130,6 +132,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
aboutMenu: '关于 DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: '版本 V{version}',
hideApplication: '隐藏 DeepSeek Harness',
hideOtherApplications: '隐藏其他',
showAllApplications: '显示全部',
@@ -215,7 +219,7 @@ export const zh = {
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
updateAvailable: '发现可用更新',
updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。',
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。',
installAndRestart: '安装并重启',
later: '稍后',
updateFailedTitle: '更新失败',
+13 -1
View File
@@ -276,6 +276,13 @@ async function main(): Promise<void> {
}
finally { ordinaryDialogs.delete(controller) }
}
// Copy comes from the same locale as the update prompts so the dialog
// chrome and its content never mix languages.
const showAbout = async (): Promise<void> => {
await ordinaryMessageBox({ type: 'info', title: locale.messages.aboutMenu, message: locale.messages.aboutProduct,
detail: formatDesktopMessage(locale.messages.aboutVersion, { version: app.getVersion() }),
buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
}
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
const applicationUrl = `${SCHEME}://app/`
let hostUrl: string | undefined
@@ -753,7 +760,12 @@ async function main(): Promise<void> {
{ role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
: []
const applicationItems = (): MenuItemConstructorOptions[] => [
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
// Windows has no system About panel; Electron's fallback is a plain
// message box, so the shell shows its own dimmed dialog instead.
process.platform === 'win32'
? { label: currentDesktopLocale().messages.aboutMenu,
click: () => { void showAbout().catch((error: unknown) => { console.error(error) }) } }
: { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
{ type: 'separator' },
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
...development ? [
+28 -2
View File
@@ -1,5 +1,5 @@
{
"en-US": {
"darwin:en-US": {
"menu": [
{ "label": "About DeepSeek Harness", "role": "about" },
{ "type": "separator" }
@@ -12,7 +12,7 @@
"iconPath": "<app icon>"
}
},
"zh-CN": {
"darwin:zh-CN": {
"menu": [
{ "label": "关于 DeepSeek Harness", "role": "about" },
{ "type": "separator" }
@@ -24,5 +24,31 @@
"copyright": "",
"iconPath": "<app icon>"
}
},
"win32:zh-CN": {
"menu": [
{ "label": "关于 DeepSeek Harness" },
{ "type": "separator" }
],
"options": {
"applicationName": "DeepSeek Harness",
"applicationVersion": "1.0.0",
"version": "",
"copyright": "",
"iconPath": "<app icon>"
}
},
"win32:en-US": {
"menu": [
{ "label": "About DeepSeek Harness" },
{ "type": "separator" }
],
"options": {
"applicationName": "DeepSeek Harness",
"applicationVersion": "1.0.0",
"version": "",
"copyright": "",
"iconPath": "<app icon>"
}
}
}
+19 -2
View File
@@ -405,7 +405,7 @@ describe('desktop main startup', () => {
['darwin', false, 'zh-CN'],
['win32', true, 'zh-CN'],
['win32', false, 'en-US'],
] as const)('offers the native About panel before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => {
] as const)('offers the About command before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => {
vi.stubGlobal('process', { ...process, platform })
harness.app.isPackaged = packaged
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
@@ -415,9 +415,26 @@ describe('desktop main startup', () => {
const submenu = applicationMenuItems()
const options = harness.app.setAboutPanelOptions.mock.calls[0]![0]
const expected = JSON.parse(readFileSync(new URL('./expected/about-panel.json', import.meta.url), 'utf8')) as Record<string, unknown>
expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '<app icon>' } }).toEqual(expected[locale])
const [about, separator] = submenu
expect({ menu: [{ label: about!.label, role: about!.role }, separator], options: { ...options, iconPath: '<app icon>' } })
.toEqual(expected[`${platform}:${locale}`])
expect(options.iconPath).toBe(packaged ? join('desktop-test-resources', 'icon.png')
: join('desktop-test-app', 'resources', 'icon-windows.png'))
if (platform !== 'win32') { expect(about!.click).toBeUndefined(); return }
// Windows reuses the dimmed update dialog because Electron's fallback is a bare message box.
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
;(about!.click as () => void)()
await vi.advanceTimersByTimeAsync(0)
const zh = locale === 'zh-CN'
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
type: 'info', title: zh ? '关于 DeepSeek Harness' : 'About DeepSeek Harness', message: 'DeepSeek Harness',
detail: zh ? '版本 V1.0.0' : 'Version V1.0.0', buttons: [zh ? '确定' : 'OK'], cancelId: 0,
}))
// A dialog that cannot open is logged, not surfaced as an unhandled rejection.
harness.dialog.showMessageBox.mockRejectedValueOnce(new Error('overlay unavailable'))
;(about!.click as () => void)()
await vi.advanceTimersByTimeAsync(0)
expect(console.error).toHaveBeenLastCalledWith(expect.objectContaining({ message: 'overlay unavailable' }))
})
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
+48
View File
@@ -482,6 +482,54 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the file menu aligned while dragging the floating review', async () => {
const column = page.locator('[data-rightbar-col]')
const tab = column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' })
const tabBox = await tab.boundingBox()
if (tabBox === null) throw new Error('review tab is not rendered')
await page.mouse.move(tabBox.x + 6, tabBox.y + tabBox.height / 2)
await page.mouse.down()
try {
await page.mouse.move(360, 140, { steps: 8 })
} finally {
await page.mouse.up()
}
const floating = page.locator('[data-dockkit-float]').filter({ has: page.locator('[data-changes-review]') })
await floating.waitFor({ state: 'visible' })
const selector = floating.getByRole('button', { name: '选择要查看的文件' })
await selector.click()
const menu = page.getByRole('menu')
await menu.waitFor({ state: 'visible' })
const grip = floating.locator('[data-dockkit-float-grip]')
for (const delta of [{ x: 120, y: 60 }, { x: -60, y: -30 }]) {
const handle = await grip.boundingBox()
const before = await selector.boundingBox()
if (handle === null || before === null) throw new Error('floating review controls are not rendered')
const start = { x: handle.x + 12, y: handle.y + handle.height / 2 }
await page.mouse.move(start.x, start.y)
await page.mouse.down()
try {
await page.mouse.move(start.x + delta.x, start.y + delta.y, { steps: 8 })
await expect.poll(async () => {
const anchor = await selector.boundingBox()
const list = await menu.boundingBox()
if (anchor === null || list === null) throw new Error('review file menu disappeared during drag')
return Math.max(
Math.abs(anchor.x - before.x - delta.x), Math.abs(anchor.y - before.y - delta.y),
Math.abs(list.x - anchor.x), Math.abs(list.y - anchor.y - anchor.height - 4),
)
}).toBeLessThanOrEqual(1)
} finally {
await page.mouse.up()
}
}
await page.keyboard.press('Escape')
await floating.locator('[data-dockkit-float-dock]').click()
await floating.waitFor({ state: 'detached' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('keeps edge gestures inside either diff column and accepts reverse scrolling', async () => {
const review = page.locator('[data-changes-review]')
await review.getByRole('button', { name: '自动换行' }).click()
+8 -8
View File
@@ -73,7 +73,7 @@ describe('web e2e: Desktop update workspace chrome', () => {
const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en)
await publish(progress)
const downloading = page.getByRole('button', { name: '58%…', exact: true })
const downloading = page.getByRole('button', { name: '58%', exact: true })
await downloading.waitFor()
expect(await downloading.getAttribute('aria-disabled')).toBe('true')
expect(await downloading.locator('svg').count()).toBe(1)
@@ -86,20 +86,20 @@ describe('web e2e: Desktop update workspace chrome', () => {
const expand = locale === 'zh-CN' ? '打开侧边栏' : 'Open sidebar'
await page.getByRole('button', { name: collapse, exact: true }).click()
const toggle = page.getByRole('button', { name: expand, exact: true })
const badge = toggle.getByRole('img', { name: '58%…', exact: true })
const badge = toggle.getByRole('img', { name: '58%', exact: true })
await badge.waitFor()
await expect.poll(() => downloading.count()).toBe(0)
expect(await page.getByRole('img', { name: '58%…', exact: true }).count()).toBe(1)
const blue = await badge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(await page.getByRole('img', { name: '58%', exact: true }).count()).toBe(1)
const updateBackground = await badge.evaluate(element => getComputedStyle(element).backgroundColor)
await page.screenshot({ path: join(evidence, 'collapsed.png') })
const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en)
await publish(error)
const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true })
await errorBadge.waitFor()
expect(await errorBadge.getAttribute('data-error')).toBe('true')
const red = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(red).not.toBe(blue)
// A failure keeps the update palette; only the label distinguishes it.
const failedBackground = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(failedBackground).toBe(updateBackground)
await errorBadge.hover()
const tooltip = page.getByRole('tooltip', { name: errorDetail, exact: true })
await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1'])
@@ -132,7 +132,7 @@ describe('web e2e: Desktop update workspace chrome', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
expect(await page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1)
await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, blue, red,
await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, updateBackground, failedBackground,
passed: true, explicitActions: 3, carrier: 'substituted', host: 'real Web composition',
electron: false, installerExecuted: false }, null, 2) + '\n')
console.log(`Desktop workspace chrome evidence: ${evidence}`)
@@ -1,5 +1,5 @@
- banner:
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes.md" an
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes-this-i
- text: Standard mode
- button "More actions"
- button "Open right sidebar"
@@ -7,7 +7,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- button "/user-invoke-demo"
- button "meeting notes.md"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Worked
@@ -1,5 +1,5 @@
- banner:
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes.md" an
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes-this-i
- text: Standard mode
- button "More actions"
- button "Open right sidebar"
@@ -7,7 +7,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- button "/user-invoke-demo"
- button "meeting notes.md"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Worked
+6 -2
View File
@@ -34,6 +34,10 @@ const MODE = webSnapshotMode()
const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
// The edited value is deliberately multi-line: the inline editor must keep the
// line breaks through the queue mutation and into the model-bound user message.
const EDITED_CONTENT = 'Edited\nqueue item'
// The dock preview flattens the queued text, so row locators match this form.
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
const WAKE = 'Wake the preserved queue'
@@ -187,7 +191,7 @@ describe('web e2e: queue row actions', () => {
const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT })
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
await editor.fill(EDITED)
await editor.fill(EDITED_CONTENT)
const save = page.getByRole('button', { name: 'Save queued message' })
await save.hover()
const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true })
@@ -308,7 +312,7 @@ describe('web e2e: queue row actions', () => {
.toEqual(['aborted', 'completed', 'completed', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
: [])).toEqual([ACTIVE_PROMPT, EDITED_CONTENT, TAIL, WAKE])
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+16 -2
View File
@@ -30,7 +30,8 @@ const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
const MODE = webSnapshotMode()
const SKILL_NAME = 'user-invoke-demo'
const ARGS_TEXT = '@"meeting notes.md" and confirm the fixture wiring'
const FILE_NAME = 'meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md'
const ARGS_TEXT = `@"${FILE_NAME}" and confirm the fixture wiring`
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
@@ -78,7 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
paceMs: 10,
})
await seedUserOnlySkill(scaffold.workspaceCwd)
await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting notes.md'), '# Meeting notes\n\nSent reference preview.\n')
await writeFile(join(scaffold.workspaceCwd, 'workspace', FILE_NAME), '# Meeting notes\n\nSent reference preview.\n')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -162,6 +163,19 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
await skill.click()
await expect.poll(() => preview.textContent(), { timeout: 10_000 }).toContain('Reply with the fixture acknowledgement line.')
const file = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="file"]').first()
const bounds = await file.evaluate((element) => {
const bubble = element.closest('[class*="bubble"]')
if (bubble === null) throw new Error('file reference has no user bubble')
const label = element.getBoundingClientRect()
const container = bubble.getBoundingClientRect()
return {
left: label.left - container.left, right: container.right - label.right,
height: label.height, lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight),
}
})
expect(bounds.left).toBeGreaterThanOrEqual(0)
expect(bounds.right).toBeGreaterThanOrEqual(0)
expect(bounds.height).toBeGreaterThan(bounds.lineHeight)
await file.hover()
expect(await file.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
await file.click()
+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 docs/config-catalog.md
config-catalog.md: 4fb13b563fd851cd3269e36f5f4aac3643def550
config-catalog.zh.md: a5dabd69a8a4357a9c32aeb6dd748f4d51e30861
config-catalog.md: cb6a44b1f33b95aefd8d6a037760a6ea36f05298
config-catalog.zh.md: 72cdcda45722744065fe30b2e544df6ff9af0b6f
+1 -1
View File
@@ -679,7 +679,7 @@ Requires: `credentials` · `authorization`
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Native desktop identity for Host API and embedded Platform requests; null omits the client platform header. */
/** Native desktop identity for Host API and embedded Platform requests; null identifies the client as web. */
desktopPlatform?: 'darwin' | 'win32' | null
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
+1 -1
View File
@@ -681,7 +681,7 @@ export interface Config {
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Native desktop identity for Host API and embedded Platform requests; null omits the client platform header. */
/** Native desktop identity for Host API and embedded Platform requests; null identifies the client as web. */
desktopPlatform?: 'darwin' | 'win32' | null
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
+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 docs/user/guide/providers.md
providers.md: 81ccba8b0665fa1f7666b5724aab4a8b3d183bbf
providers.zh.md: 1afb7e7bf616206290e5cc4a90ef45b4250a9596
providers.md: 773ff6722a8558720a458f7574940972cf976958
providers.zh.md: 6d618ed1e9eec17e8c4a000d00cc6987d0a6af14
+16 -14
View File
@@ -20,7 +20,7 @@ Providers that sign in with OAuth, such as Codex, are not supported here yet.
## Add a custom model API
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
@@ -44,6 +44,8 @@ The generated [plugin configuration catalog](../../config-catalog.md) lists ever
::: tip Additional settings
The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types. Configure reasoning effort levels, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
For the standard Web UI launch with `dsh web`, `<profile>` is `web`, so the path is `$DSH_HOME/profiles/web/cordis.patch.yml`. If you launch a custom profile, use the name selected at startup instead.
:::
### Image input
@@ -132,14 +134,14 @@ Each key is a level the menu offers, and its value is the spelling sent on the w
An `off` left empty sends nothing, which only stops a model that thinks on request; an `off` given a value sends that value as `reasoning_effort` instead. A model that thinks unless told not to — DeepSeek V4 behind an OpenAI-compatible gateway, for example — needs `compat.thinkingFormat: deepseek`, which makes `off` send `thinking: {type: disabled}` and every other level send `thinking: {type: enabled}` beside the effort:
```yaml
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
```
A built-in provider's model whose gateway does not reason loses its levels with `reasoningEfforts: false` under `modelOverrides`; selecting an effort for it is then refused as `UNSUPPORTED_REASONING_EFFORT`. DeepSeek's own route needs none of this: its models already offer `off`, `low`, `high`, and `max`, and `llm-deepseek.reasoningEffort` sets the default the picker starts from:
@@ -174,11 +176,11 @@ Two account for most of it. A model that declares reasoning has its system promp
A route's `compat` is the default for its models, and a model's own wins field by field, so one model can be corrected without restating the route:
```yaml
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
```
What neither sets keeps the installed catalog's value for that model, and what the catalog does not describe falls to pi-ai's detection. Give every switch you name a value: a key left empty (`supportsDeveloperRole:`) is refused rather than ignored, because an empty value would erase what the catalog knows while saying nothing in its place. A name no protocol accepts is refused too, and the message lists the ones that are available.
+16 -14
View File
@@ -20,7 +20,7 @@
## 添加自定义模型 API
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
@@ -44,6 +44,8 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
::: tip 其他设置
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
按常规方式通过 `dsh web` 启动 Web UI 时,`<profile>` 就是 `web`,完整路径为 `$DSH_HOME/profiles/web/cordis.patch.yml`。如果使用自定义 profile,请替换为启动时指定的名称。
:::
### 图片输入
@@ -132,14 +134,14 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
留空的 `off` 什么都不发送,这只能让「按请求才思考」的模型停下来;给 `off` 一个值,则会把该值作为 `reasoning_effort` 发送。对于「不明确关闭就会思考」的模型——例如 OpenAI 兼容网关后面的 DeepSeek V4——需要 `compat.thinkingFormat: deepseek`:它让 `off` 发送 `thinking: {type: disabled}`,其他每个等级则在 effort 之外再发送 `thinking: {type: enabled}`:
```yaml
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
```
网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
@@ -174,11 +176,11 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
路由的 `compat` 是其模型的默认值,模型自身的则逐字段胜出,因此更正某一个模型无需重述整条路由:
```yaml
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
```
两者都未设置的字段,沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测。凡是写下的开关都要给值:冒号后留空的键(`supportsDeveloperRole:`)会被拒绝而不是被忽略,因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。
@@ -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-conversation/README.md
README.md: a58ae14a450a18ae4bc89dc0dc5cc6fb664d48e0
README.zh.md: 20d6cab01e7b5fc4c9b075a993783c9df8121413
README.md: 48df40483632a7c63ec59d389f920d4dd663155b
README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d
+1 -1
View File
@@ -68,7 +68,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
+1 -1
View File
@@ -68,7 +68,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
@@ -124,7 +124,8 @@
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
/* Grows with the inline editor's content; the other rows stay 36px. */
min-height: 36px;
padding: 4px 5px 4px 12px;
border-radius: 8px;
}
@@ -211,13 +212,17 @@
.editor {
box-sizing: border-box;
height: 28px;
padding: 0 8px;
min-height: 28px;
/* Six 20px text lines plus padding and border, then the editor scrolls. */
max-height: 127px;
padding: 3px 8px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: 6px;
outline: none;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
overflow-y: auto;
resize: none;
}
.editor:focus {
@@ -1,5 +1,5 @@
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useMemo, useState } from 'react'
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -98,6 +98,53 @@ function QueueThumb({ attachment, loadImage, label }: {
: <img className={css.thumb} src={url} alt={label} />
}
/**
* Inline editor for one queued row. A textarea rather than an input: HTML
* strips newlines from single-line input values, so editing a multi-line
* queued message through one rewrites it as a single line. It grows with its
* content up to the CSS cap, then scrolls. Enter saves, Shift+Enter breaks the
* line, Escape cancels.
*/
function QueueEditor({ text, label, onChange, onSave, onCancel }: {
text: string
label: string
onChange: (text: string) => void
onSave: () => void
onCancel: () => void
}) {
const ref = useRef<HTMLTextAreaElement>(null)
useLayoutEffect(() => {
const node = ref.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (node === null) return
node.style.height = 'auto'
// scrollHeight excludes the border that the border-box height includes.
node.style.height = `${node.scrollHeight + node.offsetHeight - node.clientHeight}px`
}, [text])
return (
<textarea
ref={ref}
autoFocus
rows={1}
className={css.editor}
aria-label={label}
value={text}
onChange={(event) => { onChange(event.currentTarget.value) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onCancel()
return
}
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
onSave()
}}
/>
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
@@ -195,22 +242,12 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
{rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutlineRegular /></span>}
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
<QueueEditor
text={editing.text}
label={t('queue.edit')}
onChange={(text) => { setEditing({ id: row.id, text }) }}
onSave={() => { void saveEdit() }}
onCancel={() => { setEditing(null) }}
/>
)
: (
@@ -198,7 +198,7 @@ describe('QueueDock', () => {
expect((view.getByRole('button', { name }) as HTMLButtonElement).disabled).toBe(false)
}
fireEvent.click(view.getByRole('button', { name: '编辑排队消息' }))
expect((view.getByRole('textbox') as HTMLInputElement).value).toBe('等待上传')
expect((view.getByRole('textbox') as HTMLTextAreaElement).value).toBe('等待上传')
})
it('loads the durable thumbnail after replacing a local image echo', async () => {
@@ -369,7 +369,35 @@ describe('QueueDock', () => {
const view = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
expect(view.getByText(`before ${'🙂'.repeat(193)}…`)).toBeTruthy()
fireEvent.click(view.getByLabelText('编辑排队消息'))
expect((view.getByRole('textbox') as HTMLInputElement).value).toBe(text)
expect((view.getByRole('textbox') as HTMLTextAreaElement).value).toBe(text)
})
it('keeps line breaks while re-editing a multiline queued message', async () => {
const text = 'line one\n line two\n\nline four'
const snap = snapshotWith([row('i-lines', text)])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息') as HTMLTextAreaElement
expect(editor.value).toBe(text)
// Shift+Enter keeps the native line break: the handler must not consume it.
expect(fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })).toBe(true)
expect(updateQueue).not.toHaveBeenCalled()
fireEvent.change(editor, { target: { value: `${text}\nline five` } })
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-lines'), {
kind: 'edit',
content: [{ type: 'text', text: `${text}\nline five` }],
})
})
})
it('renders active actions and disables editing for mixed-content rows', () => {
@@ -477,7 +505,7 @@ describe('QueueDock', () => {
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
const editor = getByLabelText('编辑排队消息') as HTMLTextAreaElement
expect(getByLabelText('保存排队消息')).toBeTruthy()
expect(getByLabelText('取消编辑')).toBeTruthy()
expect(queryByLabelText('删除排队消息')).toBeNull()
@@ -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-primitives/README.md
README.md: df8c96e9fa4572b6371f8406ecd385b1eb80078e
README.zh.md: ef3cdeb70bbb7619f9e95bd24b5a1b97e9c159da
README.md: 7e8a6e2e4ae22d3f9e4d6413c783f4d4da9fed6e
README.zh.md: bdbd2faef9ec95a842540c861b934d47c6575e1e
+2 -2
View File
@@ -77,13 +77,13 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
### Rendering agent output
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
+2 -2
View File
@@ -77,13 +77,13 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一Line truncated
### 渲染 agent 输出
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
+11 -4
View File
@@ -122,7 +122,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @param props.align - list alignment against the anchor (default 'start').
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
* @param props.portal - render the list into document.body, fixed-positioned
* from the anchor rect (repositions on scroll/resize while open). Use when an
* from the anchor rect (follows movement and resizing while open). Use when an
* ancestor's overflow clipping would crop the in-place list; default false
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list once the pointer has left
@@ -135,8 +135,8 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open and on every
* scroll/resize; return null to skip placement for that frame.
* wrapper there races the host's layout effects). Called on open, each animation
* frame, and scroll/resize; return null to skip placement for that frame.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @param props.children - component rows rendered after `items` in the same
@@ -260,15 +260,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN)
setFixedPos({ left: x, top: y })
setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// end/top alignment and clamping use real dimensions before anything
// paints — no visible jump from a zero-size first guess.
place()
// Dragging or transforming an ancestor moves the anchor without a scroll or resize event.
const track = () => {
place()
frame = requestAnimationFrame(track)
}
let frame = requestAnimationFrame(track)
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
@@ -14,10 +14,13 @@
chip's label rode ~3px above the surrounding text in both surfaces. An
inline chip shares the consumer's baseline by construction. */
display: inline;
max-width: 100%;
margin: 0 2px;
color: var(--dsw-alias-state-business-primary);
font-weight: 500;
white-space: nowrap;
white-space: inherit;
overflow-wrap: anywhere;
text-align: inherit;
}
/* Skill and command tokens read as code: the theme's code family at the
@@ -1,8 +1,8 @@
/**
* Display projection of reference forms in sent user text (bubble and queue
* rows). The logged model text remains the single truth; this is presentation
* only, and every part renders inline so a single-line message never breaks
* across lines. Four decoration sources, by precedence: the wire session form
* only. Inline references follow the consumer's wrapping policy and keep long
* labels within its width. Four decoration sources, by precedence: the wire session form
* `@[label](dsh-session:...)` folds to its label; exact session labels
* supplied by an adjacent recall decorate their bare `@label` mention; plain
* `@name` word-boundary tokens decorate by shape alone; and a plain `/name`
@@ -493,6 +493,34 @@ describe('Menu', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('follows an anchor moving without scroll or resize and stops tracking when closed or unmounted', () => {
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] })
let rect = new DOMRect(40, 100, 32, 28)
const getAnchorRect = vi.fn(() => rect)
const props = { portal: true, anchor: null, items, getAnchorRect, onClose: () => {} }
try {
const view = render(<Menu {...props} open />)
rect = new DOMRect(140, 180, 32, 28)
act(() => { vi.advanceTimersToNextFrame() })
expect(screen.getByRole('menu').style.left).toBe('140px')
expect(screen.getByRole('menu').style.top).toBe('212px')
view.rerender(<Menu {...props} open={false} />)
getAnchorRect.mockClear()
act(() => { vi.advanceTimersToNextFrame() })
expect(getAnchorRect).not.toHaveBeenCalled()
view.rerender(<Menu {...props} open />)
view.unmount()
getAnchorRect.mockClear()
act(() => { vi.advanceTimersToNextFrame() })
expect(getAnchorRect).not.toHaveBeenCalled()
} finally {
cleanup()
vi.useRealTimers()
}
})
it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
@@ -1,8 +1,7 @@
// @vitest-environment jsdom
/**
* Inline projection of sent user text: decoration never breaks a single-line
* message (bubble regression), and wire session forms fold to their label
* (queue-row readability).
* Inline projection of sent user text: decoration adds no block containers,
* preserves whitespace, and folds wire session forms to their label.
*/
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/react'
@@ -17,7 +16,7 @@ const project = (
render(<div data-host>{projectUserText(text, labels, slashNames, slashKind)}</div>).container.querySelector('[data-host]')!
describe('projectUserText', () => {
it('keeps a decorated single-line message on one line: every part is inline', () => {
it('keeps decorated text inline and preserves whitespace between references', () => {
const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test'])
expect(host.querySelectorAll('div').length).toBe(0)
expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试')
@@ -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-settings-general/README.md
README.md: 266fe837899f13d33e7a098b901cc34402da765e
README.zh.md: 09570948b89089dda33d6705e4f17c7cd2f8481c
README.md: 36d3fd7b260f95cffb67977e5a6f37852bf195aa
README.zh.md: 9df8fe8bc3a39fceeccfc8b08a8e18cc5eeaf752
@@ -31,7 +31,7 @@ Users reach the shell through the sidebar's bottom Settings control; feature plu
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a dot on its top expand button. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow.
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow.
### The General section
@@ -31,7 +31,7 @@ kind: "package-reference"
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以圆点显示相同状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。
### 「通用」分区
@@ -1,28 +1,30 @@
/* The entry's seat is the Settings foot row, which bleeds 2px past the sidebar
content box for the Settings hover surface. The New Session bar and panel
rows instead keep a 2px inset inside that box, so this entry gives back both
2px offsets and ends on their trailing edge rather than flush against the
sidebar edge. */
.indicator {
display: inline-flex;
display: inline-grid;
grid-template-columns: 14px max-content;
align-items: center;
gap: 6px;
column-gap: 4px;
flex: none;
margin-inline-end: 4px;
padding: 1px 8px;
height: 28px;
padding: 0 8px;
box-sizing: border-box;
border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 20%, transparent);
border-radius: 6px;
border-radius: 13px;
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent);
font: inherit;
font-family: inherit;
font-size: 12px;
line-height: 20px;
font-weight: 500;
line-height: 18px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.indicator[data-error] {
color: var(--dsw-alias-state-error-primary);
.icon {
display: grid;
place-items: center;
width: 14px;
height: 14px;
}
.badge {
@@ -36,8 +38,7 @@
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.badge[data-error], .errorDot { background: var(--dsw-alias-state-error-primary); }
.errorDot { width: 6px; height: 6px; border-radius: 50%; corner-shape: round; }
.errorDot { background: currentColor; width: 6px; height: 6px; border-radius: 50%; corner-shape: round; }
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
@@ -46,6 +46,7 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l
}
/**
* Render update status with connection-indicator geometry and brand-blue labels, including retries.
* @param props - Connection priority, sidebar width, and localized bridge-failure copy.
* @returns Desktop-only status beside the account button, or nothing in browsers.
*/
@@ -64,10 +65,12 @@ export function DesktopUpdateIndicator({ wide, hidden, t, view, onOpen }: {
const error = failed || state?.phase === 'error'
const busy = opening || (state !== undefined && BUSY_PHASES.has(state.phase))
return <Tooltip label={failed ? retryLabel : copy.detail} side="top">
<button type="button" className={css.indicator} data-error={error || undefined}
<button type="button" className={css.indicator}
aria-label={label} aria-disabled={busy} onClick={() => { if (!busy) onOpen() }}>
{error ? <span className={css.errorDot} aria-hidden="true" />
: busy ? <IconLoadingOutlineRegular className={css.spinner} size={16} /> : <IconDownloadOutlineRegular size={14} />}
<span className={css.icon} aria-hidden="true">
{error ? <span className={css.errorDot} />
: busy ? <IconLoadingOutlineRegular className={css.spinner} size={14} /> : <IconDownloadOutlineRegular size={14} />}
</span>
<span>{label}</span>
</button>
</Tooltip>
@@ -78,7 +81,7 @@ type BadgeProps = PropsRuntime<'sidebar.toggle.badge'> & PropsLocale<'settings'>
/**
* @param props - Framework-bound carrier and connection state.
* @returns A non-interactive notification on the sidebar expand button.
* @returns A non-interactive brand-blue notification on the sidebar expand button.
*/
export function DesktopUpdateBadge({ useDesktopUpdate, useConnectionState, t }: BadgeProps) {
const { presentation: state, failed } = useDesktopUpdate(value => value)
@@ -89,6 +92,6 @@ export function DesktopUpdateBadge({ useDesktopUpdate, useConnectionState, t }:
const copy = state === undefined ? { label: retryLabel, detail: retryLabel } : updateCopy(state, t)
const label = failed ? retryLabel : copy.label
return <Tooltip label={failed ? label : copy.detail} side="right">
<span role="img" aria-label={label} className={css.badge} data-error={failed || state?.phase === 'error' || undefined} />
<span role="img" aria-label={label} className={css.badge} />
</Tooltip>
}
@@ -5,7 +5,7 @@ export const zh = {
'trigger': '设置',
'desktop.update.available': '新版本',
'desktop.update.checking': '正在检查更新…',
'desktop.update.progress': '{percent}%…',
'desktop.update.progress': '{percent}%',
'desktop.update.verifying': '正在校验更新文件…',
'desktop.update.installing': '正在准备重启…',
'desktop.update.ready': '安装并重启',
@@ -45,7 +45,7 @@ export const en = {
'trigger': 'Settings',
'desktop.update.available': 'Update',
'desktop.update.checking': 'Checking for updates…',
'desktop.update.progress': '{percent}%…',
'desktop.update.progress': '{percent}%',
'desktop.update.verifying': 'Verifying update files…',
'desktop.update.installing': 'Preparing to restart…',
'desktop.update.ready': 'Install and Restart',
@@ -44,7 +44,7 @@ const kit = {
}
describe('Desktop collapsed update badge', () => {
it('shows update status, marks failures, and yields to connection feedback', () => {
it('shows update and retry status and yields to connection feedback', () => {
let state: DesktopUpdateView = { failed: false, opening: false }
let connection: 'connected' | 'connecting' | 'disconnected' = 'connected'
const props = { ...kit, t,
@@ -55,17 +55,17 @@ describe('Desktop collapsed update badge', () => {
expect(screen.queryByRole('img')).toBeNull()
state = { ...state, presentation: { phase: 'available', version: '1.0.1' } }
view.rerender(<DesktopUpdateBadge {...props} />)
expect(screen.getByRole('img', { name: 'Update' }).getAttribute('data-error')).toBeNull()
expect(screen.getByRole('img', { name: 'Update' })).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
state = { ...state, failed: true }
view.rerender(<DesktopUpdateBadge {...props} />)
expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true')
expect(screen.getByRole('img', { name: en['desktop.update.retry'] })).toBeTruthy()
state = { failed: true, opening: false }
view.rerender(<DesktopUpdateBadge {...props} />)
expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true')
expect(screen.getByRole('img', { name: en['desktop.update.retry'] })).toBeTruthy()
state = { failed: false, opening: false, presentation: { phase: 'error', failure: 'install' } }
view.rerender(<DesktopUpdateBadge {...props} />)
expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true')
expect(screen.getByRole('img', { name: en['desktop.update.retry'] })).toBeTruthy()
for (const value of ['connecting', 'disconnected'] as const) {
connection = value
view.rerender(<DesktopUpdateBadge {...props} />)
@@ -71,11 +71,10 @@ it('keeps the newest event, hides for connection priority, and invokes only the
expect(screen.queryByRole('button')).toBeNull()
await f.emit({ phase: 'downloading', version: available.version, percent: 58 })
f.view.rerender(<f.Indicator />)
fireEvent.click(screen.getByRole('button', { name: '58%…' }))
fireEvent.click(screen.getByRole('button', { name: '58%' }))
expect(f.open).toHaveBeenCalledOnce()
await f.emit({ phase: 'error', version: available.version, failure: 'download' })
const retry = screen.getByRole('button', { name: '重试更新' })
expect(retry.getAttribute('data-error')).toBe('true')
fireEvent.focus(retry)
expect((await screen.findByRole('tooltip')).textContent).toBe('下载更新失败,请重试。')
f.view.rerender(<f.Indicator wide={false} />)
@@ -90,7 +89,6 @@ it('keeps bridge failures actionable and ignores status completion after unmount
f.open.mockRejectedValueOnce(new Error('IPC unavailable'))
fireEvent.click(screen.getByRole('button', { name: '重试更新' }))
await act(async () => {})
expect(screen.getByRole('button').getAttribute('data-error')).toBe('true')
f.view.unmount()
const late = fixture()
late.view.unmount()
@@ -143,11 +141,11 @@ it('shows fallback progress and error details when the shell omits optional fiel
const f = fixture()
try {
await f.emit({ phase: 'downloading' })
const progress = screen.getByRole('button', { name: '0%…' })
const progress = screen.getByRole('button', { name: '0%' })
fireEvent.focus(progress)
expect((await screen.findByRole('tooltip')).textContent).toBe('0%…')
expect((await screen.findByRole('tooltip')).textContent).toBe('0%')
await f.emit({ phase: 'downloading', version: '1.0.1' })
fireEvent.focus(screen.getByRole('button', { name: '0%…' }))
fireEvent.focus(screen.getByRole('button', { name: '0%' }))
expect((await screen.findByRole('tooltip')).textContent).toContain('1.0.1')
await f.emit({ phase: 'error' })
fireEvent.focus(screen.getByRole('button', { name: '重试更新' }))
@@ -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/credentials/deepseek-account-platform/README.md
README.md: 34da2313ea99b989ea3a0692d2cca050116b5b6a
README.zh.md: e24036ebf7a5c431ab34abdb1f01415b7b3a4f36
README.md: 94816e5819027b0d68d39379a9905dfe02768029
README.zh.md: 9e1df8a19881960e4d4b4423f9cfa89c463d237d
@@ -11,7 +11,7 @@ New attempts map the caller’s UI language to Platform en_US or zh_CN; active a
getPlatformSession exports the stored grant only when its issuer matches platformOrigin. This Host-only operation supports native Platform embedding without widening the model/file origin configured for resolveToken.
`desktopPlatform` defaults to `null`. The Desktop profile supplies `darwin` or `win32`; Host authorization, profile, balance, and logout requests then carry `x-client-platform: desktop-mac` or `desktop-win`, overriding that header in deployment configuration. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. Other profiles omit the automatic header.
`desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin.
## Summary
@@ -11,7 +11,7 @@ kind: "package-reference"
getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导出该授权。这个仅限 Host 的操作支持原生 Platform 内嵌,不扩大 resolveToken 配置的模型及文件请求来源。
`desktopPlatform` 默认为 `null`。Desktop profile 提供 `darwin` 或 `win32`,使 Host 的授权、资料、余额和退登请求携带 `x-client-platform: desktop-mac` 或 `desktop-win`,覆盖部署配置中的同名请求头。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头;其他 profile 不自动添加平台请求头。
`desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。
## 概述
@@ -23,7 +23,7 @@ const device = z.object({ id: z.uuid() })
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Native desktop identity for Host API and embedded Platform requests; null omits the client platform header. */
/** Native desktop identity for Host API and embedded Platform requests; null identifies the client as web. */
desktopPlatform?: 'darwin' | 'win32' | null
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
@@ -112,7 +112,7 @@ export class PlatformAccount extends DeepSeekAccount {
}
this.inferenceOrigin = inference.origin
this.rewriteBrowserOrigin = resolved.rewriteBrowserOrigin
this.clientHeaders = desktopClientHeaders(resolved.desktopPlatform)
this.clientHeaders = { 'x-client-platform': 'web', ...desktopClientHeaders(resolved.desktopPlatform) }
this.requestHeaders = platformHeaders(resolved.requestHeaders)
const accountHeaders = platformHeaders(resolved.accountRequestHeaders)
this.accountRequestHeaders = { ...this.requestHeaders, ...accountHeaders }
@@ -207,7 +207,7 @@ export class PlatformAccount extends DeepSeekAccount {
const requestHeaders = { ...this.accountRequestHeaders, ...this.clientHeaders }
return { origin: this.origin, token: stored.token,
...(this.embeddedPageDist ? { embeddedPageDist: this.embeddedPageDist } : {}),
...(Object.keys(requestHeaders).length ? { requestHeaders } : {}) }
requestHeaders }
}
private async readCurrentGrant(lifetime: AbortController): Promise<z.infer<typeof grant> | null> {
@@ -239,7 +239,8 @@ it('stores a grant before redirecting, restores account presence, and signs out
expect(response.headers.get('location')).toBe(`${f.origin}/dsh/authorized?result=test&locale=zh_CN&login_source=desktop`)
expect((await f.account.getState()).status).toBe('credential-stored')
expect(await readFile(join(f.home, 'credentials.yaml'), 'utf8')).toContain('dsh_mock_test')
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test' })
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test',
requestHeaders: { 'x-client-platform': 'web' } })
expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined()
await f.account.signOut()
expect((await f.account.getState()).status).toBe('signed-out')
@@ -779,13 +780,14 @@ it('carries the configured embedded frontend selector in the private Platform se
await f.account.startSignIn('en', f.callbackOrigin, 'desktop')
await f.wait('waiting-browser')
await fetch(f.callback(), { redirect: 'manual' })
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test', embeddedPageDist: 'feat/test' })
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test', embeddedPageDist: 'feat/test',
requestHeaders: { 'x-client-platform': 'web' } })
})
it.each([
['darwin', 'desktop-mac'], ['win32', 'desktop-win'], [null, undefined],
['darwin', 'desktop-mac'], ['win32', 'desktop-win'], [null, 'web'],
] as const)('identifies %s Host API and embedded Platform requests', async (desktopPlatform, expected) => {
const f = await fixture(undefined, { Cookie: 'test_gate=synthetic', ...(desktopPlatform === null ? {} : { 'X-Client-Platform': 'web' }) },
const f = await fixture(undefined, { Cookie: 'test_gate=synthetic', 'X-Client-Platform': 'deployment-override' },
false, {}, undefined, undefined, '', desktopPlatform)
await f.account.startSignIn('en', f.callbackOrigin, 'desktop')
await f.wait('waiting-browser')
@@ -796,8 +798,8 @@ it.each([
await f.wait('waiting-browser')
await fetch(f.callback(), { redirect: 'manual' })
await readDetails(f.account)
expect(await f.account.getPlatformSession()).toMatchObject({ requestHeaders: { cookie: 'test_gate=synthetic' } })
expect((await f.account.getPlatformSession())?.requestHeaders?.['x-client-platform']).toBe(expected)
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test',
requestHeaders: { cookie: 'test_gate=synthetic', 'x-client-platform': expected } })
await f.account.signOut()
await expect.poll(f.logoutCount).toBe(1)
expect(f.receivedHeaders.map(item => item.path)).toEqual([
@@ -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/credentials/deepseek-account/README.md
README.md: 72b42ee996b809992e48ea4c124f0793d756526a
README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63
README.md: c54dc4feced92308a83c47aa0eb7033766708205
README.zh.md: 1df0ca4cdc7d55584d8dcba29706746c87dcb31f
@@ -60,4 +60,4 @@ No model request prefix changes.
The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
PlatformSession may carry private deployment requestHeaders from Host to Electron main. Consumers must exclude those headers from renderer bootstrap and restrict them to the configured origin. mergePlatformCookies preserves unrelated cookie pairs while replacing matching names.
PlatformSession may carry Host-only requestHeaders from Host to Electron main: deployment headers plus the provider's x-client-platform. Consumers must exclude those headers from renderer bootstrap and restrict them to the configured origin. mergePlatformCookies preserves unrelated cookie pairs while replacing matching names.
@@ -60,4 +60,4 @@ AccountDetails.balance 就绪时,value 保存充值钱包,bonusWallets 保
[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
PlatformSession 可将私有部署 requestHeaders 从 Host 传至 Electron 主进程。消费者必须从渲染层 bootstrap 排除这些请求头,并将其限定于配置来源。mergePlatformCookies 替换同名 Cookie,同时保留其他 Cookie。
PlatformSession 可将仅限 Host 的 requestHeaders 从 Host 传至 Electron 主进程,其中包含部署请求头和 provider 提供的 x-client-platform。消费者必须从渲染层 bootstrap 排除这些请求头,并将其限定于配置来源。mergePlatformCookies 替换同名 Cookie,同时保留其他 Cookie。
@@ -15,7 +15,7 @@ export interface PlatformSession {
readonly token: string
/** Optional dist query value selecting the embedded frontend deployment. */
readonly embeddedPageDist?: string
/** Private deployment headers for native requests; excluded from renderer bootstrap. */
/** Host-only request headers: deployment headers and the provider client identity; never exposed through renderer bootstrap. */
readonly requestHeaders?: Readonly<Record<string, string>>
}
@@ -295,18 +295,26 @@ it('reclaims the process range after an unexpected idle exit before replacing th
it('retains a worker whose idle cleanup cannot observe exit until that range is joined', async () => {
const { worker, spawn, ctx } = await fixture({ idleTimeoutMs: 100 })
await prepare(worker)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
await worker.transcribe({ audio, language: 'en' }, signal())
const handle = spawn.mock.results[0]!.value as SubprocessHandle
const joined = vi.spyOn(handle, 'waitForExit').mockRejectedValueOnce(new Error('range observation failed'))
await vi.waitFor(() => { expect(joined).toHaveBeenCalledOnce() }, { timeout: 10000 })
expect(warn).toHaveBeenCalledWith('Speech worker idle cleanup failed', expect.any(Error))
await expect(worker.transcribe({ audio, language: 'zh' }, signal())).rejects.toThrow('range observation failed')
expect(spawn).toHaveBeenCalledOnce()
expect((await worker.transcribe({ audio, language: 'zh' }, signal())).text).toBe('zh')
expect(await joined.mock.results.at(-1)!.value).toBe(true)
expect(spawn).toHaveBeenCalledTimes(2)
warn.mockRestore()
const failure = new Error('range observation failed')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const joined = vi.spyOn(handle, 'waitForExit').mockRejectedValueOnce(failure)
try {
// Subprocess ownership also calls waitForExit after the direct process exits.
await handle.done
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('Speech worker idle cleanup failed', failure)
}, { timeout: 10000 })
await expect(worker.transcribe({ audio, language: 'zh' }, signal())).rejects.toBe(failure)
expect(spawn).toHaveBeenCalledOnce()
expect((await worker.transcribe({ audio, language: 'zh' }, signal())).text).toBe('zh')
expect(await joined.mock.results.at(-1)!.value).toBe(true)
expect(spawn).toHaveBeenCalledTimes(2)
} finally {
joined.mockRestore()
warn.mockRestore()
}
})
it('retains completed preparation steps when cancellation settles the active step', async () => {
+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/llm/llm-deepseek/README.md
README.md: 8b71e435884b8ea0364e7bea0924f2f351f11ec5
README.zh.md: 90709e09b73e0448a90a1f2d224203ad3ce7bd36
README.md: 1fad3d3f832ff21c6ae1616d3637674d6c923b2b
README.zh.md: 9914f53f732d0586fa14e2f1c6b9b05ed47c6567
+1 -1
View File
@@ -110,7 +110,7 @@ When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registe
### Failures and recovery
Configuration accepts Messages only and has no `protocol` field. If resolution reports `protocol is not configurable`, remove `protocol` from the `llm-deepseek` section in `<harness home>/settings.yaml` and from any matching Cordis entry or overlay. Keep the intended `baseURL`, `apiKeyEnv`, and `models` fields. An invalid live section keeps the complete last good configuration; saving other fields in the Models card does not remove an unknown property. Edit the configuration file, then let settings reload or restart the profile; composition edits require a profile restart.
Configuration accepts Messages only and has no `protocol` field. If resolution reports `protocol is not configurable`, remove `protocol` from the `config` of the `llm-deepseek` entry in `$DSH_HOME/profiles/<profile>/cordis.patch.yml` and from any overriding home patch or command-line overlay. Keep the intended `baseURL`, `apiKeyEnv`, and `models` fields. A stored configuration rejected by adapter validation makes subsequent requests fail until corrected; saving other fields in the Models card does not remove an unknown property. Edit the configuration file, then let the profile reload it through HMR or restart the profile if HMR is disabled.
Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity.
+1 -1
View File
@@ -110,7 +110,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
### 失败与恢复
配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `<harness home>/settings.yaml` 的 `llm-deepseek` 分节以及对应 Cordis 条目或 overlay 中删除 `protocol`,保留需要的 `baseURL`、`apiKeyEnv` 和 `models` 字段。无效的实时分节会继续使用完整的最后有效配置;在模型设置卡中保存其他字段不会移除未知属性。请编辑配置文件,等待设置重新加载或重启 profile;组装配置变更需要重启 profile。
配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中 `llm-deepseek` 条目的 `config` 以及覆盖它的 home patch 或命令行 overlay 中删除 `protocol`,保留需要的 `baseURL`、`apiKeyEnv` 和 `models` 字段。已存储的配置若被适配器校验拒绝,后续请求会持续失败,直到配置修正;在模型设置卡中保存其他字段不会移除未知属性。请编辑配置文件,等待 profile 通过 HMR(热模块替换)重新加载;若未启用 HMR,则重启 profile。
成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。