diff --git a/apps/desktop/installer/assets/brand-2x.png b/apps/desktop/installer/assets/brand-2x.png index 7cf467d766..01dfb844c7 100644 Binary files a/apps/desktop/installer/assets/brand-2x.png and b/apps/desktop/installer/assets/brand-2x.png differ diff --git a/apps/desktop/installer/assets/brand-dark-2x.png b/apps/desktop/installer/assets/brand-dark-2x.png index 35c7648644..da372d2839 100644 Binary files a/apps/desktop/installer/assets/brand-dark-2x.png and b/apps/desktop/installer/assets/brand-dark-2x.png differ diff --git a/apps/desktop/installer/assets/brand-dark.png b/apps/desktop/installer/assets/brand-dark.png index bb0bf87883..3983af4e65 100644 Binary files a/apps/desktop/installer/assets/brand-dark.png and b/apps/desktop/installer/assets/brand-dark.png differ diff --git a/apps/desktop/installer/assets/brand.png b/apps/desktop/installer/assets/brand.png index e8b64ca003..9685dc2004 100644 Binary files a/apps/desktop/installer/assets/brand.png and b/apps/desktop/installer/assets/brand.png differ diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 7f47563e81..d9c31f8cd3 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -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(reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.Stage"))); const double fraction = reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0; diff --git a/apps/desktop/resources/icon-windows.png b/apps/desktop/resources/icon-windows.png index 02bc5931d3..cc8a09e311 100644 Binary files a/apps/desktop/resources/icon-windows.png and b/apps/desktop/resources/icon-windows.png differ diff --git a/apps/desktop/resources/icon-windows.svg b/apps/desktop/resources/icon-windows.svg index 4ab25b9e70..eb07a50158 100644 --- a/apps/desktop/resources/icon-windows.svg +++ b/apps/desktop/resources/icon-windows.svg @@ -1,8 +1,8 @@ - - - + + + diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index d5aa25917c..45c2d5b25e 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -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: '更新失败', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 46ce1f45c1..4e13cc34ef 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -276,6 +276,13 @@ async function main(): Promise { } 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 => { + 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 { { 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 ? [ diff --git a/apps/desktop/tests/expected/about-panel.json b/apps/desktop/tests/expected/about-panel.json index 6900f525d3..8dc6ee9063 100644 --- a/apps/desktop/tests/expected/about-panel.json +++ b/apps/desktop/tests/expected/about-panel.json @@ -1,5 +1,5 @@ { - "en-US": { + "darwin:en-US": { "menu": [ { "label": "About DeepSeek Harness", "role": "about" }, { "type": "separator" } @@ -12,7 +12,7 @@ "iconPath": "" } }, - "zh-CN": { + "darwin:zh-CN": { "menu": [ { "label": "关于 DeepSeek Harness", "role": "about" }, { "type": "separator" } @@ -24,5 +24,31 @@ "copyright": "", "iconPath": "" } + }, + "win32:zh-CN": { + "menu": [ + { "label": "关于 DeepSeek Harness" }, + { "type": "separator" } + ], + "options": { + "applicationName": "DeepSeek Harness", + "applicationVersion": "1.0.0", + "version": "", + "copyright": "", + "iconPath": "" + } + }, + "win32:en-US": { + "menu": [ + { "label": "About DeepSeek Harness" }, + { "type": "separator" } + ], + "options": { + "applicationName": "DeepSeek Harness", + "applicationVersion": "1.0.0", + "version": "", + "copyright": "", + "iconPath": "" + } } } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 26c31d906c..13ef5ed9f0 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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 - expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '' } }).toEqual(expected[locale]) + const [about, separator] = submenu + expect({ menu: [{ label: about!.label, role: about!.role }, separator], options: { ...options, iconPath: '' } }) + .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 () => { diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 523a27d6a6..8365c34f45 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -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() diff --git a/apps/web/tests/desktop-updates.e2e.ts b/apps/web/tests/desktop-updates.e2e.ts index 1e30848020..87903d8050 100644 --- a/apps/web/tests/desktop-updates.e2e.ts +++ b/apps/web/tests/desktop-updates.e2e.ts @@ -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}`) diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index ddece2d20f..930e9eb385 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -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 diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index ddece2d20f..930e9eb385 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -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 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 0f8f0f89fc..c5317cb3fb 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -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([]) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index bd051dab25..a7788ea7d8 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -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 { @@ -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() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 28c4dc11ad..899d5b6a99 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4fb13b563fd851cd3269e36f5f4aac3643def550 -config-catalog.zh.md: a5dabd69a8a4357a9c32aeb6dd748f4d51e30861 +config-catalog.md: cb6a44b1f33b95aefd8d6a037760a6ea36f05298 +config-catalog.zh.md: 72cdcda45722744065fe30b2e544df6ff9af0b6f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4fb13b563f..cb6a44b1f3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a5dabd69a8..72cdcda457 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index e3c0fdb127..7461948374 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 81ccba8b0665fa1f7666b5724aab4a8b3d183bbf -providers.zh.md: 1afb7e7bf616206290e5cc4a90ef45b4250a9596 +providers.md: 773ff6722a8558720a458f7574940972cf976958 +providers.zh.md: 6d618ed1e9eec17e8c4a000d00cc6987d0a6af14 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 81ccba8b06..773ff6722a 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -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//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`, `` 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. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 1afb7e7bf6..6d618ed1e9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -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//cordis.patch.yml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。 + +按常规方式通过 `dsh web` 启动 Web UI 时,`` 就是 `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 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 192a0da89b..99eba4e710 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: a58ae14a450a18ae4bc89dc0dc5cc6fb664d48e0 -README.zh.md: 20d6cab01e7b5fc4c9b075a993783c9df8121413 +README.md: 48df40483632a7c63ec59d389f920d4dd663155b +README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a58ae14a45..48df404836 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 20d6cab01e..f5c3465726 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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。 diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 516ba7e83a..ca1abd25d2 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -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 { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 22f0730795..03146f3e4c 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -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 }: { : {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(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 ( +