Merge remote-tracking branch 'origin/master' into fix/multiline-display-math

This commit is contained in:
Chinesezjc committed 2026-09-22 17:10:31 +08:00
commit e8a3a5548c
172 files changed
+3534 -611

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
2026-09-14-deepseek-account-login.md: 458e020c2000fd3db5ee9806e99d4b653710cb1e
2026-09-14-deepseek-account-login.zh.md: 2fb59009302309a67e2c3ec99050685ba39b4212
@@ -36,7 +36,7 @@ The account plugin supplies choice, waiting, failure and timeout dialogs through
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
@@ -36,7 +36,7 @@ API Key 与账号记录独立保存。账号 token 没有过期或刷新流程
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md
2026-09-21-persistent-pwsh-keeps-controlled-prompt.md: 3fd42b77d4536215f34c32643526dbb03d21674f
2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md: 88d4e2deef26af4ad92ef4a69d8ae17403027f70
@@ -0,0 +1,37 @@
# Agent Note: Persistent pwsh keeps the backend's controlled prompt
Status: implemented
English | [中文](2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md)
## Problem
`dsh-tool-pwsh-persistent` initialized its shell with a `prompt` function of its own (`'__DSH_PERSISTENT_PWSH_PROMPT__ '`), overwriting the `prompt` that `dsh-terminal-bash` installs in its pwsh startup sequence. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled `dsh> ` prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could settle through it and every send paid the silence tier plus handoff grace. Measured on Windows through the real Loader composition with production defaults: 8493 ms for the first call (spawn, initialization, and command) and 3722/3832/3759 ms for the next three, against 1340/255/251/241 ms with the controlled prompt intact. The package tests masked it by configuring `idleSilenceMs: 300`.
The [persistent bash tool fixed the identical defect](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md) by giving up its own prompt override; the pwsh twin kept it. The override served the same two consumers there: a viewport-suffix fallback detecting "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output.
## Decision
The tool no longer installs a prompt. `dsh-terminal-bash` owns the pwsh `prompt` function, installs it during `spawn`, and returns the session only after that startup reached `stdin_read` readiness, so the tool's initialization send had nothing left to establish and is deleted together with its `PWSH_PROMPT_SETUP` command.
The viewport-suffix fallback is replaced with the seam's own signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know. `stripPrompt` collapses to `trimTrailingNewline`, matching `dsh-tool-bash-persistent`.
## Alternatives considered
**Align the tool's prompt text with the backend's `dsh> `.** Rejected: it keeps a second installation of one protocol constant and a second owner of it, and the send is redundant because the backend had already installed the identical prompt before `spawn` resolved.
**Import the controlled prompt into the tool.** Rejected: the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted pwsh-dialect backend would break it again.
**Lower `handoffGraceMs`/`idleSilenceMs` instead.** Rejected: no silence value fixes a dead fast path; it rebalances how much every call overpays, and the silence tier is also what absorbs a genuinely broken prompt.
**Re-assert the pwsh prompt per prompt, as `PROMPT_COMMAND` does for bash.** Rejected: PowerShell has no per-prompt hook outside the `prompt` function itself, so a redefinition cannot be healed the way bash's variable is; the residue stays a documented limitation.
## Consequences
Windows, production defaults, real Loader composition: tool calls drop from 8493/3722/3832/3759 ms to 1340/255/251/241 ms (spawn + initialization + first command, then three warm commands).
The `stdin_read` fallback is behavior, not only cosmetics: where a provider proves a foreground stdin wait, an interactive child returns captured partial output instead of spinning to the command deadline, and elsewhere the call still runs to `timeoutMs`. That early return keeps the session, so a child still reading stdin can consume the next call's command until that call reaches `timeoutMs` and resets the shell; `dsh-tool-bash-persistent` behaves the same way, and distinguishing the shell's own prompt readiness from a child's stdin wait belongs to the seam rather than to either tool. Complete marker-delimited output is unchanged.
The loader-composition suite records every send's `waitReason` and requires at least six `stdin_read` settlements and no `inferred_idle`: the regression is reported by the settle reason itself, not by how long silence takes. Reinstating the removed prompt override fails that assertion after 30 s with `expected 0 to be greater than or equal to 6`, while the case's output assertions still pass. The stub suite drops the initialization-handshake modes and pins the prompt text retained in partial output instead of its absence.
A model command that redefines `prompt` still removes the readiness marker and degrades later sends to the silence tier: the backend installs its prompt once at startup and cannot re-assert it.
@@ -0,0 +1,37 @@
# Agent Note: 持久 pwsh 保留后端的受控提示符
Status: implemented
[English](2026-09-21-persistent-pwsh-keeps-controlled-prompt.md) | 中文
## Problem
`dsh-tool-pwsh-persistent` 用自己安装的 `prompt` 函数(`'__DSH_PERSISTENT_PWSH_PROMPT__ '`)初始化其 shell,覆盖了 `dsh-terminal-bash` 在 pwsh 启动序列中安装的 `prompt`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符 `dsh> ` 完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)),因此初始化之后任何 send 都无法经由该路径结算,每次 send 都要支付静默层加交接宽限。在 Windows 上通过真实 Loader 组合以生产默认值实测:首次调用(spawn、初始化与命令)8493 毫秒,随后三次为 3722/3832/3759 毫秒;受控提示符完好时为 1340/255/251/241 毫秒。包测试把 `idleSilenceMs` 配成 300,掩盖了该问题。
[持久 bash 工具已修复同一缺陷](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md),方式是放弃自己的提示符覆盖;pwsh 孪生工具保留了它。该覆盖在两个工具中服务同样的两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。
## Decision
工具不再安装提示符。pwsh 的 `prompt` 函数由 `dsh-terminal-bash` 拥有,在 `spawn` 期间安装,且只在该启动流程达到 `stdin_read` 就绪后才返回会话,因此工具的初始化 send 已无可确立之事,与其 `PWSH_PROMPT_SETUP` 命令一并删除。
视口后缀回退替换为 seam 自己的信号:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。`stripPrompt` 收敛为 `trimTrailingNewline`,与 `dsh-tool-bash-persistent` 一致。
## Alternatives considered
**把工具的提示符文本对齐到后端的 `dsh> `。** 被拒绝:这保留了同一个协议常量的第二份安装与第二个拥有者,而且该 send 是多余的——后端在 `spawn` 返回前就已安装相同的提示符。
**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他 pwsh 方言后端都会再次损坏。
**改为调小 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少;而静默层同时也是吸收真正损坏提示符的那一层。
**像 bash 的 `PROMPT_COMMAND` 那样在每个提示符处重新设定 pwsh 提示符。** 被拒绝:除 `prompt` 函数本身之外,PowerShell 没有每提示符钩子,因此重定义无法像 bash 的变量那样被治好;该残余已记入已知限制。
## Consequences
Windows、生产默认值、真实 Loader 组合:工具调用从 8493/3722/3832/3759 毫秒降至 1340/255/251/241 毫秒(spawn + 初始化 + 首条命令,随后三条热命令)。
`stdin_read` 回退是行为而不只是美化:在提供方能证明前台 stdin 等待之处,交互式子进程会返回已捕获的部分输出,而不是空转到命令期限;其他情况下调用仍会运行到 `timeoutMs`。该提前返回保留会话,因此仍在读取 stdin 的子进程可能吞掉下一次调用的命令,直到那次调用到达 `timeoutMs` 并重置 shell;`dsh-tool-bash-persistent` 行为相同,而区分「shell 自身的提示符就绪」与「子进程的 stdin 等待」属于 seam,而不是任一工具。由标记界定的完整输出保持不变。
loader 组合套件记录每次 send 的 `waitReason`,要求至少六次 `stdin_read` 且没有 `inferred_idle`:回归由结算原因本身报告,而不是靠静默层耗时。把删掉的提示符覆盖加回去,该断言会在 30 秒后以 `expected 0 to be greater than or equal to 6` 失败,而同一用例的输出断言仍然通过。stub 套件删除了初始化握手模式,并改为锚定部分输出中保留的提示符文本,而不是它的缺席。
重定义 `prompt` 的模型命令仍会移除就绪标记,并把后续 send 降级到静默层:后端只在启动时安装一次提示符,无法重新设定。
+1
View File
@@ -143,6 +143,7 @@ pnpm applies local patches to the following packages at install time, so shipped
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
## Official Claude Code platform payloads
+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 apps/desktop/README.md
README.md: 711e65fb5feaea601800218801d138a0df9b3f97
README.zh.md: 183d195a9af485c9ffe5a3e5bf65e3a459582b93
README.md: 59f18ea269f303e267d5e9597d8748e2a8fed134
README.zh.md: eb1594e032fea2da38f778e6b2217bd52587af91
+1 -1
View File
@@ -405,7 +405,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
+1 -1
View File
@@ -405,7 +405,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
+16 -2
View File
@@ -127,6 +127,20 @@ function chromeFallbackFill(): string {
return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb'
}
/**
* Add the effective Desktop palette to a Platform authorization URL so the
* login page opens in the application's theme. `system` resolves through
* `nativeTheme.shouldUseDarkColors`, which follows the theme source the
* application preload publishes.
* @param authorizeUrl - validated Platform authorization URL.
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
*/
function platformLoginUrl(authorizeUrl: string): string {
const url = new URL(authorizeUrl)
url.searchParams.set('theme', nativeTheme.shouldUseDarkColors ? 'dark' : 'light')
return url.href
}
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
const window = new BrowserWindow({
width: 1280,
@@ -340,7 +354,7 @@ async function main(): Promise<void> {
const attempt = state.attempt
if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
openedAttempt = attempt.id
void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
void shell.openExternal(platformLoginUrl(attempt.authorizeUrl)).catch(() => undefined)
}
if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
returnedAttempt = attempt.id
@@ -904,7 +918,7 @@ async function main(): Promise<void> {
if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
throw new Error('desktop welcome: login link is unavailable')
}
await clipboard.writeText(state.attempt.authorizeUrl)
await clipboard.writeText(platformLoginUrl(state.attempt.authorizeUrl))
},
saveApiKey: async (apiKey) => {
if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
+9 -8
View File
@@ -1,4 +1,4 @@
/** Mirrors the Web UI's theme source into the macOS native theme so window vibrancy follows the app palette. */
/** Mirrors the Web UI's theme source into Electron's native theme so native chrome and Platform login pages follow the app palette. */
import { ipcRenderer } from 'electron'
import { DESKTOP_IPC } from './ipc.ts'
@@ -7,14 +7,15 @@ import { DESKTOP_IPC } from './ipc.ts'
const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
/**
* On macOS, watches `html[data-ds-theme-source]` and forwards each value to
* the main process, which sets `nativeTheme.themeSource` — the sidebar
* vibrancy material then follows the app's theme preference instead of the
* OS appearance, while `system` keeps following the OS. Other platforms
* never send.
* Watches `html[data-ds-theme-source]` and forwards each value to the main
* process, which sets `nativeTheme.themeSource`. Native chrome and renderer
* `prefers-color-scheme` queries on every platform then follow the app's
* theme preference instead of the OS appearance while `system` keeps
* following the OS; the macOS sidebar vibrancy material is one such consumer.
* The main process reads the same value back as `shouldUseDarkColors` when a
* Platform login link needs the resolved palette.
*/
export function syncNativeTheme(): void {
if (process.platform !== 'darwin') return
let sent: string | undefined
const send = (): void => {
const value = document.documentElement.getAttribute(THEME_SOURCE_ATTRIBUTE)
@@ -28,6 +29,6 @@ export function syncNativeTheme(): void {
}
// The preload runs before the document root exists; the theme bootstrap
// script writes the attribute before DOMContentLoaded.
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe)
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe, { once: true })
else observe()
}
+19 -1
View File
@@ -165,7 +165,7 @@ const harness = await vi.hoisted(async () => {
get publishUpdate() { return publishUpdate! },
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
openExternal: vi.fn(),
openExternal: vi.fn(async () => {}),
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
disableAllPlugins: vi.fn(async () => {
@@ -1634,3 +1634,21 @@ it.each(['failed', 'expired'] as const)('focuses DSH once when browser authoriza
harness.publishAccount(state)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it.each([['light', false], ['dark', true]] as const)('opens Platform authorization in the effective %s palette', async (theme, shouldUseDarkColors) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
harness.nativeTheme.shouldUseDarkColors = shouldUseDarkColors
const state: AccountView = {
status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
attempt: { id: 'test-theme-attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser',
authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=state-1' },
}
harness.publishAccount(state)
harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
})
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { DESKTOP_IPC } from '../src/ipc.ts'
import { syncNativeTheme } from '../src/preload-theme.ts'
const send = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ ipcRenderer: { send } }))
/** Observers created by the module under test; jsdom keeps one document per file. */
const observers = new Set<MutationObserver>()
const NativeMutationObserver = MutationObserver
/** Records every observer so teardown detaches it from the shared document. */
class TrackedMutationObserver extends NativeMutationObserver {
constructor(callback: MutationCallback) {
super(callback)
observers.add(this)
}
}
afterEach(() => {
for (const observer of observers) observer.disconnect()
observers.clear()
document.documentElement.removeAttribute('data-ds-theme-source')
vi.unstubAllGlobals()
vi.restoreAllMocks()
send.mockClear()
})
it.each(['darwin', 'win32'] as const)('mirrors the application theme source on %s', async (platform) => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
document.documentElement.setAttribute('data-ds-theme-source', 'dark')
syncNativeTheme()
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'dark')
document.documentElement.setAttribute('data-ds-theme-source', 'system')
await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.nativeThemeSet, 'system') })
expect(send).toHaveBeenCalledTimes(2)
})
it('defers observation until the document root exists', () => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
syncNativeTheme()
expect(send).not.toHaveBeenCalled()
document.documentElement.setAttribute('data-ds-theme-source', 'light')
window.dispatchEvent(new Event('DOMContentLoaded'))
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'light')
})
+48 -1
View File
@@ -6,26 +6,73 @@ import { describe, expect, it, vi } from 'vitest'
const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
/**
* Browser globals a sandboxed Electron preload sees. A preload runs while the
* document is still loading, so the theme preload defers its first read to
* `DOMContentLoaded` and observes later `html[data-ds-theme-source]` changes.
* @returns context globals plus the root attribute transitions the Web UI performs.
*/
function browserEnvironment() {
const attributes = new Map<string, string>()
const listeners = new Map<string, Array<() => void>>()
const observers: Array<() => void> = []
const document = {
readyState: 'loading',
documentElement: {
dataset: {} as Record<string, string>,
getAttribute: (name: string): string | null => attributes.get(name) ?? null,
setAttribute: (name: string, value: string): void => { attributes.set(name, value) },
},
}
const window = {
addEventListener: (type: string, listener: () => void): void => {
listeners.set(type, [...listeners.get(type) ?? [], listener])
},
}
class SandboxMutationObserver {
constructor(callback: () => void) { observers.push(callback) }
observe(): void {}
}
return {
globals: { document, window, MutationObserver: SandboxMutationObserver },
/** Writes the theme root attribute and settles the document, as the theme bootstrap does before DOMContentLoaded. */
loadTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const listener of listeners.get('DOMContentLoaded') ?? []) listener()
},
/** Applies a later theme preference change through the observer the preload registered. */
changeTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const observer of observers) observer()
},
}
}
describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
const exposed = new Map<string, Record<string, unknown>>()
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
const send = vi.fn()
const browser = browserEnvironment()
const electron = {
contextBridge: { exposeInMainWorld: (key: string, value: Record<string, unknown>) => { exposed.set(key, value) } },
ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
}
runInNewContext(readFileSync(preload(name), 'utf8'), {
...browser.globals,
require: (id: string) => {
if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
return electron
},
process: { argv: ['electron', '--dsh-welcome-locale=en'] },
location: new URL('dsh-app://app/'),
document: { documentElement: { dataset: {} } },
exports: {},
})
if (name === 'preload-app') {
browser.loadTheme('dark')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'dark')
browser.changeTheme('light')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'light')
const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
bridge.read()
expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
+8 -3
View File
@@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({
windowOptions: undefined as BrowserWindowConstructorOptions | undefined,
menu: vi.fn(),
operations: undefined as WelcomeOperations | undefined,
nativeTheme: { themeSource: 'system', shouldUseDarkColors: false },
}))
vi.mock('electron', () => ({
@@ -67,7 +68,7 @@ vi.mock('electron', () => ({
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
nativeTheme: { themeSource: 'system' },
nativeTheme: state.nativeTheme,
session: { defaultSession: {
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
@@ -177,11 +178,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
attempt: { id: attemptId, phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
state.accountState.mockResolvedValue(account)
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login')
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login?theme=light')
state.nativeTheme.shouldUseDarkColors = true
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenLastCalledWith('https://example.test/login?theme=dark')
state.nativeTheme.shouldUseDarkColors = false
await expect(state.operations!.copySignInLink('stale' as typeof attemptId)).rejects.toThrow('login link is unavailable')
state.accountState.mockResolvedValue({ ...account, attempt: { id: attemptId, phase: 'expired' } })
await expect(state.operations!.copySignInLink(attemptId)).rejects.toThrow('login link is unavailable')
expect(state.copy).toHaveBeenCalledOnce()
expect(state.copy).toHaveBeenCalledTimes(2)
await state.operations!.skip()
expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
+14 -11
View File
@@ -121,24 +121,27 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
within(contextPanel).getByText('Tool definitions')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/
// write row first. The write turn's `hello fixture\n` proves the terminator
// rule end to end: a trailing newline terminates its line, so the footer reads
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
// ::before, so it is absent from textContent — assert on the line body and the
// footer.
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
// The built ToolRow and DiffBlock share the write fixture's trailing-newline
// semantics: `hello fixture\n` contributes one addition. Counts belong to the
// tool row; the diff's line markers are CSS pseudo-elements, absent from textContent.
const mutationRows = [...document.querySelectorAll<HTMLElement>('[data-variant="write"],[data-variant="edit"]')]
expect(mutationRows.length).toBeGreaterThan(0)
const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write'
&& row.textContent?.includes('new-demo.txt'))
if (writeRow === undefined) throw new Error('fixture write row missing')
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeRow.querySelector('[data-diff]')).toBeNull()
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
if (toggle !== null) act(() => { fireEvent.click(toggle) })
}
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
const writeDiff = writeRow.querySelector<HTMLElement>('[data-diff]')
if (writeDiff === null) throw new Error('expanded fixture write diff missing')
within(writeDiff).getByText('hello fixture', { exact: true })
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeDiff.textContent).not.toContain('+1 -0')
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
+160
View File
@@ -0,0 +1,160 @@
/** Browser geometry for read offsets and horizontally scrolled diff fills. */
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
const LONG_LINE = 'source_columns_'.repeat(80)
const OFFSETS = [999, 99_999]
/** Seed durable tool results without invoking a model or allocating a large file. */
function layoutFixture(): string {
const calls: { name: string; args: Record<string, JsonValue>; result: string; meta: JsonValue }[] = OFFSETS.map((offset) => {
const path = `offset-${offset}.txt`
const lines = [{ number: offset, text: 'short' }, { number: offset + 1, text: LONG_LINE }]
return {
name: 'read', args: { file_path: path, offset },
result: `<path>${path}</path>\n<type>file</type>\n<content>\n${lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n</content>`,
meta: { path, offset, lines, totalLines: offset + 2 },
}
})
const oldText = `old_${LONG_LINE}\nshort old`
const newText = `new_${LONG_LINE}\nshort new`
calls.push({
name: 'edit', args: { file_path: 'long.txt', old_string: oldText, new_string: newText },
result: 'Edited long.txt', meta: { diffs: [{ path: 'long.txt', oldText, newText }] },
})
const session = Session.create(SessionId('code-card-layout-source'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Inspect the source windows and edit the long lines.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
const namedCalls = calls.map((call, index) => ({ ...call, id: ToolCallId(`layout-${index}`), arguments: JSON.stringify(call.args) }))
session.append('assistant/message', {
stream: [], turn: 1, step: 1,
message: createAssistantMessage({
content: namedCalls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: call.arguments })),
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
for (const call of namedCalls) {
const source = session.append('tool/call', { turn: 1, step: 1, callId: call.id, name: call.name, arguments: call.arguments })
session.append('tool/result', {
turn: 1, step: 1, meta: call.meta,
message: createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: call.result }], isError: false }),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
stream: [], turn: 1, step: 2,
message: createAssistantMessage({
content: [{ type: 'text', text: 'CODE_LAYOUT_DONE' }], source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }),
...session.snapshotEvents().map(event => JSON.stringify(event)), '',
].join('\n')
}
describe('web e2e: code-card layout', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, layoutFixture(), 'code-card-layout')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.getByText('CODE_LAYOUT_DONE', { exact: true }).waitFor()
await expandTurnProcesses(page)
for (const row of await page.locator('[data-variant="read"], [data-variant="edit"]').all()) {
await row.locator('[data-expandable]').click()
}
await page.locator('[data-read]').nth(1).waitFor()
await page.locator('[data-diff]').waitFor()
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it.each(OFFSETS)('keeps offset %i line numbers clear of aligned source in both wrap modes', async (offset) => {
onTestFailed(() => saveFailureShot(page, `code-card-offset-${offset}`))
const card = page.locator('[data-read]').filter({ hasText: `offset-${offset}.txt` })
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
const heights: number[] = []
for (const wrapped of [false, true]) {
if (wrapped) await wrap.click()
expect(await wrap.getAttribute('aria-pressed')).toBe(String(wrapped))
const rows = await card.locator('[class*="gutter"]').evaluateAll(gutters => gutters.map((gutter) => {
const content = gutter.nextElementSibling!
const range = document.createRange()
range.selectNodeContents(gutter)
const glyphs = range.getBoundingClientRect()
const slot = gutter.getBoundingClientRect()
const source = content.getBoundingClientRect()
return {
fits: glyphs.left >= slot.left - 1 && glyphs.right <= slot.right + 1,
gap: source.left - glyphs.right, left: source.left, height: source.height,
}
}))
expect(rows).toHaveLength(2)
expect(rows.every(row => row.fits && row.gap >= 11)).toBe(true)
expect(rows[0]!.left).toBe(rows[1]!.left)
heights.push(rows[1]!.height)
}
expect(heights[1]!).toBeGreaterThan(heights[0]!)
await wrap.click()
})
it.each([false, true])('fills long diff rows through horizontal scrolling with dark=%s', async (dark) => {
onTestFailed(() => saveFailureShot(page, `code-card-diff-${dark}`))
await page.evaluate(value => document.body.toggleAttribute('data-ds-dark-theme', value), dark)
const card = page.locator('[data-diff]')
const body = card.locator(':scope > div').nth(1)
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
for (const wrapped of [false, true]) {
if (wrapped) await wrap.click()
const result = await body.evaluate((element) => {
element.scrollLeft = element.scrollWidth
const viewport = element.getBoundingClientRect()
const rows = [...element.children].filter(row => ['+ ', '- '].includes(getComputedStyle(row, '::before').content.replaceAll('"', '')))
return {
overflow: element.scrollWidth - element.clientWidth,
fills: rows.map(row => ({
width: row.getBoundingClientRect().width, right: row.getBoundingClientRect().right,
fill: getComputedStyle(row).backgroundColor, marker: getComputedStyle(row).boxShadow,
})),
scrollWidth: element.scrollWidth, right: viewport.right,
}
})
expect(result.fills).toHaveLength(4)
if (wrapped) expect(result.overflow).toBeLessThanOrEqual(1)
else expect(result.overflow).toBeGreaterThan(100)
for (const row of result.fills) {
expect(row.width).toBeGreaterThanOrEqual(result.scrollWidth - 1)
expect(row.right).toBeGreaterThanOrEqual(result.right - 1)
expect(row.fill).toMatch(dark ? /0\.12\)$/ : /0\.08\)$/)
expect(row.marker).toContain('inset')
}
}
await wrap.click()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
+2 -1
View File
@@ -55,7 +55,8 @@ describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => {
const card = edit.locator('[data-diff]')
await card.waitFor()
expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1)
expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`)
expect(await edit.getByText(scenario.totals, { exact: true }).count()).toBe(1)
expect(await card.textContent()).not.toContain(scenario.totals)
const snapshotDir = `${ROOT}/web/${scenario.name}`
await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`,
await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
+58 -1
View File
@@ -104,6 +104,58 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
}
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
let browser: Browser | undefined
try {
browser = await chromium.launch()
const page = await newEnglishPage(browser)
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const column = page.locator('[data-rightbar-col]')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'budget.xlsx', exact: true }).click()
const excel = column.locator('[data-excel-preview]')
await excel.locator('.luckysheet-sheets-item-name').getByText('公式与格式', { exact: true }).click()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
const formula = excel.locator('.fortune-fx-input')
await expect.poll(() => formula.innerText()).toBe('46281')
const canvas = excel.locator('canvas').first()
const originalCanvas = await canvas.elementHandle()
const original = await canvas.boundingBox()
if (original === null || originalCanvas === null) throw new Error('spreadsheet canvas is unavailable')
const panel = page.locator('[data-sidebar-right-panel]')
const panelBounds = await panel.boundingBox()
if (panelBounds === null) throw new Error('sidebar panel is unavailable')
const viewport = page.viewportSize()
const layout = await page.addStyleTag({ content: `[data-sidebar-right-panel] { width: ${panelBounds.width + 160}px !important; }` })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width - original.width)).toBe(160)
await expect.poll(() => formula.innerText()).toBe('46281')
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
expect(page.viewportSize()).toEqual(viewport)
await successShot(page, 'excel-resize-wide')
await layout.evaluate((node) => { node.textContent = '' })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
expect(await formula.innerText()).toBe('46281')
await successShot(page, 'excel-resize-restored')
await originalCanvas.dispose()
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
})
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -799,7 +851,12 @@ else process.exit(1);
})).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
await page.evaluate(() => navigator.clipboard.writeText(''))
await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
const copyCode = codeBlock.getByRole('button', { name: 'Copy', exact: true })
expect(await codeBlock.getByRole('button').count()).toBe(1)
expect(await copyCode.textContent()).toBe('')
await copyCode.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
await copyCode.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
sections.push([
'## Code paging', '',
+92
View File
@@ -0,0 +1,92 @@
/** Independent-writer XLSX previews through a replayed Session and the shipped browser Worker. */
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { excelOpeningCases, excelOpeningFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts'
import { excelOpcCases, excelOpcFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts'
import { excelXmlCases, excelXmlFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts'
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const snapshotDirectory = fileURLToPath(new URL('../../../snapshots/web/excel-opc', import.meta.url))
const mode = webSnapshotMode()
describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let consoleErrors: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
developerTools: false,
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
compareReplaySession: false,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
consoleErrors = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('opens comments, Tables and equivalent OPC encodings without hiding cell data', async () => {
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
await input.press('Enter')
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
if (cwd === undefined) throw new Error('Excel preview Session has no workspace')
for (const name of excelOpcCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpcFixture(name))
for (const name of excelOpeningCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpeningFixture(name))
for (const name of excelXmlCases) await writeFile(join(cwd, `${name}.xlsx`), await excelXmlFixture(name))
const column = page.locator('[data-rightbar-col]')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor()
await column.locator('[data-files-reload]').click()
const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
const preview = column.locator('[data-textpreview-url]')
const excel = preview.locator('[data-excel-preview]')
const sheets = excel.locator('.luckysheet-sheets-item-name')
const notice = excel.locator('[data-excel-unsupported-notice]')
const rows = ['# Independent-writer Excel previews', '']
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) {
await files.click()
await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true)
await sheets.getByText('数据', { exact: true }).waitFor()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('Item')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toMatch(/^Item\r?\n$/)
const rich = excelXmlCases.some(candidate => candidate === name)
const opening = excelOpeningCases.some(candidate => candidate === name)
expect(await notice.count()).toBe(name === 'combined' || rich || opening ? 1 : 0)
if (name === 'combined' || rich || opening) {
const features = rich ? 'charts, images, shapes, conditional formatting'
: name === 'orphan-drawing' ? 'conditional formatting' : 'charts, conditional formatting'
expect(await notice.innerText()).toBe(`This preview does not support ${features} in this workbook. Open it in a system application for the full experience.`)
}
rows.push(`- ${name}.xlsx: 数据 opened; Item copied`)
}
expect(await sheets.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
const warning = await notice.innerText()
expect(warning).toBe('This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.')
rows.push('- Relocated workbook: hidden sheet omitted', `- Relocated workbook notice: ${warning}`)
expect(consoleErrors.pageErrors).toEqual([])
expect(consoleErrors.warnings).toEqual([])
await compareOrRefreshGolden(join(snapshotDirectory, 'excel.expected.md'), rows.join('\n'), mode)
await assertFixtureInventory(snapshotDirectory, ['excel.expected.md'])
})
})
@@ -34,8 +34,10 @@
- text: Edit
- button "src/tokens.css"
- text: +1 -1
- text: css
- button "Wrap lines"
- button "Copy"
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file"
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5;"
- button "Inspect"
- button "Tool call str_replace_editor · create" [expanded]
- text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md"
@@ -45,7 +47,8 @@
- button "Read docs/guide.md" [expanded]:
- text: Read
- button "docs/guide.md"
- text: docs/guide.md
- text: Code block docs/guide.md
- button "Wrap lines"
- button "Copy"
- text: "# Link style guide guide line 2 guide line 3 guide line 4"
- button "Expand 4 more lines": … 4 more lines
@@ -136,6 +139,7 @@
- paragraph:
- img "Token preview"
- text: css
- button "Wrap lines" [pressed]
- button "Copy"
- code: "--inline-code: #F5F5F5;"
- paragraph: LINK_GALLERY_DONE
@@ -23,6 +23,7 @@
- tab "Host" [selected]
- tabpanel "Host":
- text: javascript
- button "Wrap lines" [pressed]
- button "Copy"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
@@ -0,0 +1,4 @@
- text: Code block
- button "Wrap lines" [pressed]
- button "Copy"
- code: echo WATERFALL
@@ -214,6 +214,7 @@
- text: .
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
- text: typescript
- button "Wrap lines" [pressed]
- button "Copy"
- code: const value = "reasoning code"
- paragraph: {{longToken}}
+25
View File
@@ -27,6 +27,8 @@ const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
@@ -218,6 +220,28 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
}, 90_000)
it.skipIf(MODE === 'record')('renders recorded Markdown with icon tooltips and switchable wrapping', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-code-card'))
await ensureSeedOpen(page)
const selector = '.md-code-block:has-text("echo WATERFALL")'
const block = page.locator(selector)
await block.waitFor({ timeout: 15_000 })
await compareOrRefreshGolden(CODE_CARD_EXPECTED, await captureStableAria(page, selector, scaffold.workspaceCwd), MODE)
const code = block.locator('pre')
const source = await code.textContent()
const copy = block.getByRole('button', { name: 'Copy', exact: true })
await copy.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
const unwrap = block.getByRole('button', { name: 'Wrap lines', exact: true })
await unwrap.hover()
await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor()
await unwrap.click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre')
await block.getByRole('button', { name: 'Wrap lines', exact: true }).click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
expect(await code.textContent()).toBe(source)
}, 60_000)
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
@@ -521,6 +545,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
}, 60_000)
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md', 'timing.expected.md',
+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([])
@@ -11,6 +11,7 @@
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Wrap lines" [pressed]
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -85,7 +85,7 @@ async function fenceTree(block: ReturnType<Page['locator']>): Promise<FenceTree>
const pre = element.querySelector<HTMLPreElement>('pre.shiki')
if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
return {
language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
language: element.querySelector('[data-code-block-banner] [class*="language"]')?.textContent ?? '',
pre: {
className: pre.className,
style: pre.style.cssText,
+9 -3
View File
@@ -170,6 +170,8 @@ describe('web e2e: secondary Thinking Markdown', () => {
await page.evaluate(async () => { await document.fonts.ready })
const styles = await markdown.evaluate((root, secondary) => {
const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
const prose = elements.filter(element => element.closest('pre') === null)
const code = elements.filter(element => element.closest('pre') !== null)
const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')]
const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short '))
// Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink.
@@ -190,9 +192,13 @@ describe('web e2e: secondary Thinking Markdown', () => {
const banner = root.querySelector('[data-code-block-banner]')?.parentElement
const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
return {
secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
secondaryLine: elements.filter(element => !element.classList.contains('katex'))
secondarySize: prose.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
secondaryLine: prose.filter(element => !element.classList.contains('katex'))
.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
codeTypography: code.length > 0 && code.every((element) => {
const style = getComputedStyle(element)
return style.fontSize === '11px' && style.lineHeight === '19px'
}),
tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
tertiaryMarkers: [...root.querySelectorAll('li')]
.every(element => getComputedStyle(element, '::marker').color === secondary.color),
@@ -216,7 +222,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
}
}, summaryStyle)
expect(styles, `Thinking typography at ${String(width)}px`).toEqual({
secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
secondarySize: true, secondaryLine: true, codeTypography: true, tertiaryColor: true, tertiaryMarkers: true,
contained: true, bannerStatic: true, looseSpacing: true,
wideContentBounded: true, displayMathScrolls: true,
shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true,
+2
View File
@@ -26,6 +26,7 @@
"tests/server-restart.e2e.ts",
"tests/default-product-isolation.e2e.ts",
"tests/diff-context.e2e.ts",
"tests/code-card-layout.e2e.ts",
"tests/scaffold.ts",
"tests/voice-input.e2e.ts",
"tests/voice-setup.e2e.ts",
@@ -53,6 +54,7 @@
"tests/details-session-lifecycle.e2e.ts",
"tests/sessionless-header.e2e.ts",
"tests/document-preview.e2e.ts",
"tests/excel-opc.e2e.ts",
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
"tests/plugin-config.e2e.ts",
+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: 4d2fe53661495ecaa2010dc466ae4a5e3df8508a
config-catalog.zh.md: 3f38d368e2bcfe5ac2c7d407e1a82c0972d1af48
config-catalog.md: d65619dfdb2f1f8406f93ba32cdcca5b27dace75
config-catalog.zh.md: 1dc8a03f22c98b1e388032bd51f0a5e5b2f65c6e
+2 -2
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
@@ -3507,7 +3507,7 @@ export interface Config {
}
```
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
<a id="deepseek-aidsh-tool-ralph"></a>
+2 -2
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
@@ -3509,7 +3509,7 @@ export interface Config {
}
```
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
<a id="deepseek-aidsh-tool-ralph"></a>
+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 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。
+3
View File
@@ -7,6 +7,9 @@ export const en = {
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'codeBlock.title': 'Code block',
'codeBlock.wrap': 'Wrap lines',
'codeBlock.unwrap': 'Do not wrap lines',
'copy.failed': 'Copy failed',
'copy.value': 'Copy value',
'copy.json': 'Copy JSON',
+3
View File
@@ -5,6 +5,9 @@ export const zh = {
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'codeBlock.title': '代码块',
'codeBlock.wrap': '自动换行',
'codeBlock.unwrap': '取消自动换行',
'copy.failed': '复制失败',
'copy.value': '复制值',
'copy.json': '复制 JSON',
@@ -1,6 +1,7 @@
/** Read-only help stays local to Settings and never changes the selected preset. */
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './PresetGuideDialog.module.css'
@@ -35,10 +36,10 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined
/** Curated usage dictionaries contain only level-three example sections. */
function GuideUsage({ text, t }: {
text: string
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
}): ReactNode {
const labels = {
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}
return text.split(/(?=^### )/m).map((section) => {
@@ -67,7 +68,7 @@ function GuideUsage({ text, t }: {
export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
guide: PresetGuide
initialPage: PresetGuidePage
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
onClose: () => void
}): ReactNode {
const [page, setPage] = useState(initialPage)
@@ -144,7 +145,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
<MarkdownText
text={t(guide.explanation)}
labels={{
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}}
/>
@@ -10,7 +10,7 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
*/
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}
}
@@ -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。
@@ -307,8 +307,6 @@ export const zh = {
'tool.autoReviewRejected': 'Auto review 已拒绝',
'tool.autoReviewNotExecuted': '工具未执行。原因:{reason}',
'tool.autoReviewReasonFallback': 'Auto review 未授权此次操作',
'diff.files.one': '{count} 个文件',
'diff.files.other': '{count} 个文件',
'diff.collapseAria': '收起差异',
'diff.expandAria': '展开其余 {count} 行差异',
'diff.expandRest': '… 其余 {count} 行',
@@ -661,8 +659,6 @@ export const en = {
'tool.autoReviewRejected': 'Rejected by Auto review',
'tool.autoReviewNotExecuted': 'Tool was not executed. Reason: {reason}',
'tool.autoReviewReasonFallback': 'Auto review did not authorize this action',
'diff.files.one': '{count} file',
'diff.files.other': '{count} files',
'diff.collapseAria': 'Collapse diff',
'diff.expandAria': 'Expand {count} more diff lines',
'diff.expandRest': '… {count} more lines',
@@ -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()
@@ -16,10 +16,12 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
/**
* Root attribute publishing the theme source (`light`, `dark`, or `system`)
* for host shells that mirror it into native window chrome (the Electron
* preload forwards it to `nativeTheme.themeSource` so macOS vibrancy follows
* the app theme). `system` only when the preference is `system`; a fixed
* preference (including registered theme ids) publishes its resolved scheme.
* for host shells that mirror it into the native theme (the Electron preload
* forwards it to `nativeTheme.themeSource`, so native chrome, renderer
* `prefers-color-scheme` queries, and Platform login links follow the app
* palette on every platform). `system` only when the preference is `system`;
* a fixed preference (including registered theme ids) publishes its resolved
* scheme.
*/
export const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
@@ -22,7 +22,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
const params = tab.tab.navigation.params
const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
const labels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}), [t])
if (plan === undefined) return (
@@ -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: dc5a2de052ad7ab57e46c69ba21941344d7aa670
README.zh.md: 5a3fb46810548289b5813a9da6df1f5b50e0b011
README.md: 7e1487745f83b49eea911e04133aa465a2da2cd5
README.zh.md: 4a778c5f82e641c90d2b6690a1d168df2277f669
+3 -1
View File
@@ -87,7 +87,9 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
`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.
`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
`CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown shares this toolbar, spacing, and code typography while retaining monochrome text. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout.
`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from tool-summary totals. The card ends after the diff body. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
+3 -1
View File
@@ -87,7 +87,9 @@ kind: "package-library"
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,工具摘要的统计不计入共享上下文。卡片在差异正文后结束。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
@@ -0,0 +1,83 @@
.card {
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: 12px;
font: var(--dsw-font-markdown-code-block);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 18px 8px 22px;
background: var(--dsl-code-block-background, var(--dsw-alias-markdown-code-block));
border-top-left-radius: inherit;
border-top-right-radius: inherit;
color: var(--dsw-alias-label-secondary);
font: 11px/18px var(--dsw-font-family);
}
.heading {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.language {
color: var(--dsw-alias-label-tertiary);
flex: none;
font-family: var(--ds-font-family-code);
}
.title {
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
align-items: center;
gap: 4px;
flex: none;
}
.status {
margin-right: 8px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
background: transparent;
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.body {
padding: 6px 22px 20px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
}
@@ -0,0 +1,62 @@
/** Shared language, wrapping, and clipboard controls for code cards. */
import {
IconCheckOutlineRegular, IconCopyOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular,
} from './icons/index.tsx'
import { Tooltip } from './Tooltip.tsx'
import { supportsHighlighting } from './markdown/highlight.ts'
import css from './CodeCard.module.css'
/** Localized language fallback and wrapping actions supplied by the card owner. */
export interface CodeToolbarLabels {
/** Title for an absent or unsupported language. */
codeLabel: string
/** Action that enables wrapping. */
wrapLabel: string
/** Action that preserves source columns with horizontal scrolling. */
unwrapLabel: string
}
/** Display state and callbacks for a code card's toolbar. */
interface CodeToolbarProps {
lang?: string | undefined
title?: string | undefined
status?: string | undefined
labels: CodeToolbarLabels
copyLabel: string
copiedLabel: string
copied: boolean
wrapped: boolean
onCopy?: (() => void) | undefined
onWrap?: (() => void) | undefined
}
/**
* Render a language label and keyboard-accessible icon actions with tooltips.
* @param props - Localized labels, current state, and card-owned actions.
* @returns The shared code-card header.
*/
export function CodeToolbar({ lang, title, status, labels, copyLabel, copiedLabel, copied, wrapped, onCopy, onWrap }: CodeToolbarProps) {
const wrapLabel = wrapped ? labels.unwrapLabel : labels.wrapLabel
const clipboardLabel = copied ? copiedLabel : copyLabel
return (
<div className={css.header} data-code-block-banner>
<div className={css.heading}>
<span className={css.language}>{supportsHighlighting(lang) ? lang : labels.codeLabel}</span>
{title !== undefined && <span className={css.title} title={title}>{title}</span>}
</div>
<div className={css.actions}>
{status !== undefined && <span className={css.status}>{status}</span>}
{onWrap !== undefined && <Tooltip label={wrapLabel} side="top" portal>
<button type="button" className={css.action} aria-label={labels.wrapLabel} aria-pressed={wrapped} onClick={onWrap}>
{wrapped ? <IconNowrapFillRegular size={14} /> : <IconWrapFillRegular size={14} />}
</button>
</Tooltip>}
{onCopy !== undefined && <Tooltip label={clipboardLabel} side="top" portal>
<button type="button" className={css.action} aria-label={clipboardLabel} onClick={onCopy}>
{copied ? <IconCheckOutlineRegular size={14} /> : <IconCopyOutlineRegular size={14} />}
</button>
</Tooltip>}
</div>
</div>
)
}
@@ -1,79 +1,58 @@
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-diff-radius);
}
/* The copy control floats in the top-right corner over the body, so the card
has no empty banner row above its first diff line (the TUI diff card has no
banner either — only the footer). The block is position: relative, so this
anchors to the card. */
.copyButton {
position: absolute;
top: 8px;
right: 12px;
z-index: 1;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
overflow: hidden;
}
.body {
padding: 12px 14px;
display: grid;
grid-template-columns: minmax(max-content, 1fr);
padding: 6px 0 20px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: a diff is read by its indentation. */
.line {
min-height: var(--dsl-diff-line-height);
box-sizing: border-box;
min-height: 1lh;
padding: 0 22px;
white-space: pre;
}
/* A file header: the path in the primary tone, set apart by weight. The copy
button floats over this first row's top-right corner, so reserve space at the
line's end for it — a long path scrolls under the button otherwise, and the
button's hit area would eat clicks on the path's tail. */
.block[data-code-wrap='true'] .body {
grid-template-columns: minmax(0, 1fr);
}
.block[data-code-wrap='true'] .line {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.path {
color: var(--dsw-alias-label-primary);
font-weight: 600;
padding-right: 56px;
color: var(--dsw-alias-label-secondary);
font-weight: 500;
}
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
.gap {
color: var(--dsw-alias-label-tertiary);
}
/* The diff's own meaning-carrying colors: removed on the error token, added on
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
shown line agree, and so the sign reads without relying on color alone. */
.del::before {
content: '- ';
color: var(--dsw-alias-state-error-primary);
}
.del {
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-code-diff-deleted);
box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary);
}
.add::before {
content: '+ ';
color: var(--dsw-alias-state-success-primary);
}
.add {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-code-diff-added);
box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary);
}
.context {
@@ -87,9 +66,9 @@
.expand {
display: block;
width: 100%;
padding: 0;
padding: 0 22px;
border: none;
background-color: transparent;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
@@ -99,11 +78,3 @@
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
footer the TUI transcript's diff card draws. */
.footer {
padding: 0 14px 12px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}
+17 -21
View File
@@ -3,6 +3,9 @@ import clsx from 'clsx'
import { structuredPatch } from 'diff'
import { FoldToggle } from './FoldToggle.tsx'
import { writeClipboard } from './clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
import { languageForPath } from './code-highlighting.ts'
import cardCss from './CodeCard.module.css'
import css from './DiffBlock.module.css'
/** Output lines shown before the height cap collapses the middle. */
@@ -33,14 +36,13 @@ export interface DiffBlockProps {
}
/** Localized chrome for {@link DiffBlock}. */
export interface DiffBlockLabels {
export interface DiffBlockLabels extends CodeToolbarLabels {
copy: string
copied: string
collapseAria: string
expandAria: (hidden: number) => string
collapse: string
expand: (hidden: number) => string
files: (count: number) => string
}
/** A single rendered body line and its role, so the height cap slices a flat list. */
@@ -82,7 +84,7 @@ function localHunks(diff: DiffHunk) {
* comparisons exceeding the edit limit count both complete fragments as replaced.
* Text follows {@link contentLines}'s terminator rule.
* @param diffs - the hunks to count.
* @returns the +/- totals for summaries and the card footer.
* @returns the +/- totals for tool summaries.
*/
export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
let added = 0
@@ -99,18 +101,16 @@ export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number
}
/**
* Flatten local patches into rows and count only added and removed lines.
* Flatten local patches into rows.
* A path header opens each new file. A `⋯` gap separates consecutive same-file
* fragments and distant patches within a fragment. File counts use distinct paths.
* fragments and distant patches within a fragment.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
* @returns the body rows.
*/
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
function buildRows(diffs: DiffHunk[]): DiffRow[] {
const rows: DiffRow[] = []
const paths = new Set<string>()
let prevPath: string | undefined
for (const diff of diffs) {
paths.add(diff.path)
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
@@ -122,12 +122,7 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
}
}
}
return {
rows,
added: rows.filter(row => row.kind === 'add').length,
removed: rows.filter(row => row.kind === 'del').length,
files: paths.size,
}
return rows
}
/**
@@ -171,9 +166,12 @@ function copyText(rows: DiffRow[]): string {
* @returns the diff block element.
*/
export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const rows = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const [wrapped, setWrapped] = useState(false)
const firstLanguage = diffs[0] === undefined ? undefined : languageForPath(diffs[0].path)
const language = diffs.every(diff => languageForPath(diff.path) === firstLanguage) ? firstLanguage : undefined
const onCopy = useCallback(() => {
if (copied) return
@@ -198,10 +196,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
<div className={clsx(cardCss.card, css.block, className)} data-diff="" data-code-wrap={wrapped}>
<CodeToolbar lang={language} labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied}
copied={copied} wrapped={wrapped} onCopy={onCopy} onWrap={() => { setWrapped(value => !value) }} />
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
@@ -219,7 +216,6 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}>└ +{added} -{removed} · {labels.files(files)}</div>
</div>
)
}
@@ -1,105 +1,37 @@
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
min-height: 1lh;
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
margin-right: 12px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
min-width: 0;
color: var(--dsw-alias-label-primary);
}
.block[data-code-wrap='true'] .line {
white-space: pre-wrap;
}
.block[data-code-wrap='true'] .content {
overflow-wrap: anywhere;
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
padding: 0 0 0 calc(var(--dsl-read-gutter) + 12px);
border: none;
background-color: transparent;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
+16 -19
View File
@@ -1,7 +1,9 @@
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
import clsx from 'clsx'
import { FoldToggle } from './FoldToggle.tsx'
import { writeClipboard } from './clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
import cardCss from './CodeCard.module.css'
import {
grammarLoadCount,
highlightLines,
@@ -44,7 +46,7 @@ export interface ReadBlockProps {
}
/** Localized chrome for {@link ReadBlock}. */
export interface ReadBlockLabels {
export interface ReadBlockLabels extends CodeToolbarLabels {
window: (shown: number, total: number) => string
copy: string
copied: string
@@ -88,6 +90,7 @@ export function ReadBlock({
)
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const [wrapped, setWrapped] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
@@ -116,27 +119,21 @@ export function ReadBlock({
</div>
))
const gutterDigits = lines.reduce((digits, line) => Math.max(digits, String(line.number).length), 3)
const gutterStyle = { '--dsl-read-gutter': `${gutterDigits}ch` } as CSSProperties
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
return (
<div ref={rootRef} className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{labels.window(lines.length, totalLines)}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
)}
</div>
</div>
<div className={css.body}>
<div ref={rootRef} className={clsx(cardCss.card, css.block, className)} data-read="" data-code-wrap={wrapped} style={gutterStyle}>
<CodeToolbar
lang={lang} title={label} status={windowed ? labels.window(lines.length, totalLines) : undefined}
labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied} copied={copied} wrapped={wrapped}
// Empty files must not replace the clipboard with empty text.
onCopy={lines.length > 0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }}
/>
<div className={cardCss.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<FoldToggle
@@ -2,6 +2,7 @@
* Cordis-free React primitives styled only through `--dsw-*` tokens.
*/
export type { CodeToolbarLabels } from './CodeToolbar.tsx'
export { StateDot } from './StateDot.tsx'
export type { StateDotState } from './StateDot.tsx'
export { DisclosureRow } from './DisclosureRow.tsx'
@@ -104,6 +104,21 @@
color: var(--dsw-alias-label-primary);
}
.card :where(pre) {
padding: 6px 22px 20px;
word-break: normal;
overflow-wrap: anywhere;
}
.card[data-code-wrap='false'] {
--dsl-code-block-line-white-space: pre;
}
.card[data-code-wrap='false'] :where(pre) {
white-space: pre;
overflow-wrap: normal;
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
@@ -2,6 +2,7 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
import type { CSSProperties, ReactNode, Ref } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from '../CodeToolbar.tsx'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
@@ -35,6 +36,10 @@ export interface CodeBlockProps {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Enable the shared card toolbar and spacing; omit for custom toolbar layouts. */
toolbarLabels?: CodeToolbarLabels | undefined
/** With toolbarLabels, use the owner's wrapping preference and omit the toolbar's local wrap action. */
wrap?: boolean | undefined
}
/**
@@ -64,7 +69,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
}
export function CodeBlock({
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
@@ -149,6 +154,8 @@ export function CodeBlock({
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [localWrapped, setWrapped] = useState(true)
const wrapped = wrap ?? localWrapped
const onCopy = useCallback(() => {
if (copied) return
@@ -178,21 +185,26 @@ export function CodeBlock({
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, toolbarLabels !== undefined && css.card, className)}
data-line-numbers={lineNumbers || undefined}
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
{showHeader && <div className={css.bannerWrap}>
<div className={css.banner} data-code-block-banner>
{toolbarLabels !== undefined ? <CodeToolbar
lang={lang} labels={toolbarLabels} copyLabel={copyLabel} copiedLabel={copiedLabel}
copied={copied} wrapped={wrapped} onCopy={onCopy}
onWrap={wrap === undefined ? () => { setWrapped(value => !value) } : undefined}
/> : <div className={css.banner} data-code-block-banner>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>
</div>}
</div>}
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
</div>
@@ -401,24 +401,6 @@
z-index: auto;
}
.compact [data-code-block-banner] {
padding: 4px 8px;
font: inherit;
}
.compact [data-code-block-banner] > :first-child {
font-size: inherit;
line-height: inherit;
color: inherit;
}
.compact pre {
margin: 4px 0;
padding: 8px;
font-size: inherit;
line-height: inherit;
}
/* Shiki writes token colors inline; compact code uses the surrounding text color. */
.compact pre,
.compact pre :is(code, span) {
@@ -22,6 +22,7 @@ import clsx from 'clsx'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
import { CodeBlock } from './CodeBlock.tsx'
import { parseFileLink } from './file-link.ts'
import { renderTexToReact } from './katex.tsx'
@@ -36,6 +37,8 @@ export interface MarkdownCodeLabels {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Shared card controls; omitted for custom toolbar layouts. */
toolbarLabels?: CodeToolbarLabels | undefined
}
/** Localized chrome for a Markdown document. */
@@ -399,6 +402,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
toolbarLabels={context.labels.code.toolbarLabels}
/>
)
}
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { ReadBlock } from '../src/ReadBlock.tsx'
import { DiffBlock } from '../src/DiffBlock.tsx'
import { diffBlockLabels, readBlockLabels } from './labels.client.ts'
const toolbarLabels = { codeLabel: 'Code block', wrapLabel: 'Wrap lines', unwrapLabel: 'Do not wrap lines' }
const labels = { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels }
afterEach(() => {
cleanup()
vi.clearAllTimers()
vi.useRealTimers()
})
describe('code-card controls', () => {
it.each([undefined, 'unregistered'])('labels the %s language with the localized code title', (lang) => {
render(<CodeBlock code="plain source" lang={lang} {...labels} />)
expect(screen.getByText('Code block')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
})
it('keeps a known grammar name while its highlighter is not yet visible', () => {
render(<CodeBlock code="const x = 1" lang="typescript" {...labels} />)
expect(screen.getByText('typescript')).toBeTruthy()
expect(screen.queryByText('Code block')).toBeNull()
})
it.each(['code', 'read', 'diff'] as const)('toggles %s wrapping without changing source text', (kind) => {
const code = 'const longName = "a long value whose source must stay intact"'
const view = render(kind === 'code'
? <CodeBlock code={code} lang="ts" {...labels} />
: kind === 'read'
? <ReadBlock lines={[{ number: 24, text: code }]} totalLines={30} lang="ts" labels={{ ...readBlockLabels, ...toolbarLabels }} />
: <DiffBlock diffs={[{ path: 'example.ts', oldText: 'old', newText: code }]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
const button = screen.getByRole('button', { name: 'Wrap lines' })
const wasWrapped = button.getAttribute('aria-pressed') === 'true'
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
expect(button.getAttribute('aria-label')).toBe('Wrap lines')
expect(view.container.textContent).toContain(code)
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(wasWrapped))
})
it('shows hover tooltips for copying and wrapping and updates the wrapping action', () => {
render(<CodeBlock code="source" {...labels} />)
const copy = screen.getByRole('button', { name: 'Copy' })
fireEvent.mouseEnter(copy)
expect(screen.getByRole('tooltip').textContent).toBe('Copy')
fireEvent.mouseLeave(copy)
const wrap = screen.getByRole('button', { name: 'Wrap lines' })
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines')
fireEvent.click(wrap)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Wrap lines')
})
it('copies source through the icon and announces success', async () => {
vi.useFakeTimers()
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
render(<CodeBlock code={'const x = 1\n'} {...labels} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy' })) })
expect(writeText).toHaveBeenCalledWith('const x = 1')
expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()
} finally {
if (original === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', original)
}
})
it('shows compact Markdown icon actions and preserves custom source controls', () => {
const view = render(<MarkdownText text={'```ts\nconst x = 1\n```'} variant="compact" labels={{ code: labels, footnotes: 'Footnotes' }} />)
const wrap = screen.getByRole('button', { name: 'Wrap lines' })
expect(wrap.getAttribute('aria-pressed')).toBe('true')
fireEvent.click(wrap)
expect(wrap.getAttribute('aria-pressed')).toBe('false')
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
view.rerender(<CodeBlock code="source" copyLabel="Copy" copiedLabel="Copied" />)
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
})
it('uses the fallback title for diffs containing different languages', () => {
render(<DiffBlock diffs={[
{ path: 'example.ts', oldText: '', newText: 'export {}' },
{ path: 'example.py', oldText: '', newText: 'print(1)' },
]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
expect(screen.getByText('Code block')).toBeTruthy()
})
})
@@ -75,18 +75,15 @@ describe('DiffBlock structure', () => {
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
expect(changeRows(container)).toEqual(['a', 'b'])
})
it('keeps a genuine interior blank line', () => {
@@ -105,7 +102,6 @@ describe('DiffBlock local changes', () => {
const total = count === 128 ? count : count + 1
render(<DiffBlock diffs={diffs} maxLines={1000} />)
expect(diffTotals(diffs)).toEqual({ added: total, removed: total })
expect(screen.getByText(`└ +${total} -${total} · 1 file`)).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(writeText).toHaveBeenCalledWith(count === 128
? ['large.txt', ' shared context', ...oldLines.slice(1).map(line => `- ${line}`), ...newLines.slice(1).map(line => `+ ${line}`)].join('\n')
@@ -132,7 +128,6 @@ describe('DiffBlock local changes', () => {
render(<DiffBlock diffs={diffs} />)
expect(screen.getAllByText('start')).toHaveLength(1)
expect(screen.getAllByText('end')).toHaveLength(1)
expect(screen.getByText('└ +1 -1 · 1 file')).toBeTruthy()
expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(writeText).toHaveBeenCalledWith('settings.ts\n start\n- mode = 1\n+ mode = 2\n end')
@@ -150,7 +145,6 @@ describe('DiffBlock local changes', () => {
'item 40', 'changed 40', 'item 41', 'item 42', 'item 43',
])
expect(changeRows(container)).toEqual(['item 10', 'changed 10', 'item 40', 'changed 40'])
expect(screen.getByText('└ +2 -2 · 1 file')).toBeTruthy()
})
it.each([
@@ -163,26 +157,10 @@ describe('DiffBlock local changes', () => {
['a\n\nb', 'a\nb', 0, 1],
])('counts ordered changes in %j → %j', (oldText, newText, added, removed) => {
const diffs = [{ path: 'a.txt', oldText, newText }]
render(<DiffBlock diffs={diffs} />)
const { container } = render(<DiffBlock diffs={diffs} />)
expect(diffTotals(diffs)).toEqual({ added, removed })
expect(screen.getByText(`└ +${added} -${removed} · 1 file`)).toBeTruthy()
})
})
describe('DiffBlock footer', () => {
it('counts added and removed lines and one file', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
})
it('pluralizes the distinct-file count', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: null, newText: 'x' },
{ path: 'b.ts', oldText: null, newText: 'y' },
]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
expect(container.querySelectorAll('[class*="_add_"]')).toHaveLength(added)
expect(container.querySelectorAll('[class*="_del_"]')).toHaveLength(removed)
})
})
@@ -14,13 +14,14 @@ export const markdownLabels: MarkdownLabels = {
}
export const diffBlockLabels: DiffBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
expandAria: hidden => `展开其余 ${hidden} 行差异`,
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
}
export const readBlockLabels: ReadBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
window: (shown, total) => `显示 ${shown} / ${total} 行`,
copy: '复制', copied: '复制成功', collapseAria: '收起内容',
expandAria: hidden => `展开其余 ${hidden} 行`,
@@ -134,10 +134,9 @@ describe('ReadBlock banner', () => {
expect(view.queryByText(/显示/u)).toBeNull()
})
it('draws an empty label and empty language when neither is given', () => {
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
it('uses the localized code title when no language is given', () => {
render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(screen.getByText('代码块')).toBeTruthy()
})
})
@@ -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-account/README.md
README.md: f024f7ef6506c1c9317d80660aaa0c8221e6a51e
README.zh.md: 2868d46a11db60cfec57f1eee6b146316b989288
README.md: be38bcd68ab7affe4aa840499ef79009ba25b779
README.zh.md: 9429d21cbd988a538c18320acbbfcb65dd6acfc0
@@ -30,7 +30,7 @@ Balances use Platform Web currency formatting: two decimal places and digit grou
Desktop usage and top-up actions open an isolated native Platform view below a 48px return bar. Back destroys the view and preserves the Account settings page. Loading failures retain the return action and show a centered localized error with Retry. Retry reloads the same destination; renderer commands never receive the account token.
Contact us opens the Feishu questionnaire in the system browser. It supplies the available Platform UID, build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation.
The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation.
The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, and row typography; feature styles only size its launcher.
@@ -46,7 +46,7 @@ The Account section appears first in Settings only while signed in; it is hidden
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved Desktop palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports.
@@ -30,7 +30,7 @@ Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览
Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Platform 视图。返回操作销毁视图并保留 Account 设置页。加载失败时保留返回操作,居中显示本地化错误提示和“重试”按钮。重试重新加载同一目标页面;渲染进程命令不接收账号 token。
“联系我们”在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的 Platform UID、构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传 token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。
账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。
侧边栏账号菜单使用公共 Menu 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。
@@ -46,7 +46,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取 Desktop 生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。
@@ -30,7 +30,8 @@
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
@@ -52,6 +53,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/big.js": "^6.2.2",
@@ -16,9 +16,10 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
* @returns account menu launcher.
*/
export function AccountMenu({
wide, openSettings, openOnboarding, useAccount, signOut, contactUs, showLogin, start, cancel, t,
wide, openSettings, openOnboarding, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel, t,
}: AccountMenuProps) {
const account = useAccount(state => state)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
const signedIn = account.view?.status === 'credential-stored'
const profile = account.details?.profile
const label = profile === undefined ? null : profile.status === 'ready'
@@ -55,7 +56,8 @@ export function AccountMenu({
else if (id === 'signin') beginSignIn()
else void logout()
}} />
{account.loginVisible && !account.onboarding && <SignInDialog account={account} start={start} cancel={cancel} t={t}
{account.loginVisible && !account.onboarding && <SignInDialog account={account} colorScheme={colorScheme}
start={start} cancel={cancel} t={t}
close={() => { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />}
{logoutFailed && <span className={css.error} role="alert">{t('failed')}</span>}
</div>
@@ -6,12 +6,13 @@ import type { AccountSectionInjected } from './AccountSection.tsx'
import { SignInDialog } from './SignInDialog.tsx'
/** @param props - onboarding completion, account state and actions. @returns account dialog while signed out. */
export function AccountOnboarding({ complete, useApiKey, useAccount, setOnboarding, showLogin, start, cancel, t }:
export function AccountOnboarding({ complete, useApiKey, useAccount, useTheme, setOnboarding, showLogin, start, cancel, t }:
PropsRuntime<'settings.models.sign-in'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>) {
const account = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
useEffect(() => { setOnboarding(true); return () => { setOnboarding(false) } }, [setOnboarding])
useEffect(() => { if (account.view?.status === 'credential-stored') complete() }, [account.view?.status, complete])
if (!account.view || account.view.status === 'credential-stored') return null
return <SignInDialog account={account} start={start} cancel={cancel} t={t}
return <SignInDialog account={account} colorScheme={colorScheme} start={start} cancel={cancel} t={t}
close={() => { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} />
}
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react'
import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
import { formatBalance } from './formatBalance.ts'
import { AccountAvatar } from './AccountAvatar.tsx'
import { authorizeUrlWithTheme } from './authorize-url.ts'
import css from './AccountSection.module.css'
/** Safe account snapshot shared by the settings page and launcher. */
@@ -30,11 +32,15 @@ export interface AccountSectionInjected {
/** Desktop-only commands; absent in ordinary browsers. */
platform?: PlatformBridge
/** Host-owned account stream observed through framework hooks. */
hooks: { account: HostObservable<AccountSnapshot> }
/** Account stream owned by the Host and theme snapshots published by the renderer, observed through framework hooks. */
hooks: {
account: HostObservable<AccountSnapshot>
/** Palette the Platform login pages follow. */
theme: HostObservable<ThemeSnapshot>
}
/** @returns after account details are refreshed; concurrent refreshes share a request. */
refresh: () => Promise<void>
/** Open the external support questionnaire with the current account and environment. */
/** Open the external support questionnaire with the current build and browser environment. */
contactUs: () => void
/** Open or dismiss the login dialog. */
showLogin: (visible: boolean) => void
@@ -51,8 +57,9 @@ export interface AccountSectionInjected {
export type AccountSectionProps =
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>
/** @param props - localized actions and account subscription. @returns account settings UI. */
export function AccountSection({ t, useAccount, start, cancel, refresh, platform }: AccountSectionProps) {
export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh, platform }: AccountSectionProps) {
const { view: state, details, failed: streamFailed } = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>()
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
@@ -107,7 +114,8 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
</a>}
</div>
{active && <div className={css.actions}>
{attempt.authorizeUrl && <a className={css.linkButton} href={attempt.authorizeUrl} target="_blank" rel="noreferrer">
{attempt.authorizeUrl && <a className={css.linkButton} href={authorizeUrlWithTheme(attempt.authorizeUrl, colorScheme)}
target="_blank" rel="noreferrer">
{t('open')}
</a>}
<Button variant="outline" className={css.button} disabled={busy || attempt.phase === 'committing'}
@@ -4,11 +4,14 @@ import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } fro
import type { AccountSnapshot } from './AccountSection.tsx'
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { AccountKey } from './locales.ts'
import { authorizeUrlWithTheme } from './authorize-url.ts'
import css from './SignInDialog.module.css'
/** @param props - safe account state, localized copy, and user actions. @returns login dialog. */
export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: {
account: AccountSnapshot
/** Resolved scheme of the active Desktop theme; the copied link carries it. */
colorScheme: 'light' | 'dark'
start: () => Promise<void>
cancel: (id: SignInAttemptId) => Promise<void>
close: () => void
@@ -49,7 +52,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
}
const copyLink = async (authorizeUrl: string) => {
try {
await navigator.clipboard.writeText(authorizeUrl)
await navigator.clipboard.writeText(authorizeUrlWithTheme(authorizeUrl, colorScheme))
setCopyResult({ messageKey: 'copiedLink' })
} catch { setCopyResult({ messageKey: 'copyFailed' }) }
}
@@ -0,0 +1,14 @@
/** Platform authorization links that carry the Desktop palette into the login page. */
/**
* Add the resolved Desktop palette to a Platform authorization link without
* dropping any parameter the Host already put on it.
* @param authorizeUrl - authorization URL of the current account attempt.
* @param colorScheme - resolved scheme of the active Desktop theme.
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
*/
export function authorizeUrlWithTheme(authorizeUrl: string, colorScheme: 'light' | 'dark'): string {
const url = new URL(authorizeUrl)
url.searchParams.set('theme', colorScheme)
return url.href
}
@@ -1,15 +1,13 @@
/** Feishu questionnaire context follows the Platform Web ticket fields. */
import type { Config } from '../contact-config.ts'
import type { AccountProfile } from '@deepseek-ai/dsh-deepseek-account/types'
/**
* Build an external questionnaire URL without authentication credentials.
* @param config - questionnaire destination and supported source option.
* @param context - currently available account and browser environment.
* @param context - currently available build and browser environment.
* @returns questionnaire URL with hidden, optionally prefilled context fields.
*/
export function contactUrl(config: Config, context: {
uid: AccountProfile['id']
version: string | undefined
locale: string
width: number
@@ -21,7 +19,7 @@ export function contactUrl(config: Config, context: {
const width = Math.round(context.width * ratio)
const height = Math.round(context.height * ratio)
const fields = {
uid: context.uid, source: config.contactSource, app_version: context.version,
source: config.contactSource, app_version: context.version,
os_version: undefined, device_brand: undefined, device_model: undefined,
app_locale: context.locale, screen_resolution: width > 0 && height > 0 ? `${width}x${height}` : undefined,
}
@@ -30,5 +28,7 @@ export function contactUrl(config: Config, context: {
url.searchParams.delete(`prefill_${name}`)
if (value) url.searchParams.set(`prefill_${name}`, value)
}
url.searchParams.delete('prefill_uid')
url.searchParams.delete('hide_uid')
return url.href
}
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
import type { PlatformBridge } from './PlatformOverlay.tsx'
@@ -21,7 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { 'settings.account': AccountKey }
}
/** Services required by account settings. */
export const inject = ['slots', 'locale', 'remote', 'remote.account']
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'theme']
/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */
export function apply(ctx: Context): void {
if (!('dshDesktop' in globalThis)) return
@@ -81,9 +82,7 @@ export function apply(ctx: Context): void {
...nativePlatform === undefined ? {} : { platform: nativePlatform },
refresh,
contactUs() {
const profile = snapshot.details?.profile
const url = contactUrl(config, {
uid: profile?.status === 'ready' ? profile.value.id : null,
version: process.env.DSH_CLIENT_VERSION,
locale: ctx.locale.getSnapshot().active === 'zh' ? 'zh-CN' : 'en',
width: window.screen.width, height: window.screen.height, pixelRatio: window.devicePixelRatio,
@@ -92,10 +91,16 @@ export function apply(ctx: Context): void {
},
showLogin(visible) { publish({ ...snapshot, loginVisible: visible }) },
setOnboarding(active) { publish({ ...snapshot, onboarding: active }) },
hooks: { account: {
getSnapshot: () => snapshot,
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
} },
hooks: {
account: {
getSnapshot: () => snapshot,
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
},
theme: {
getSnapshot: () => ctx.theme.getTheme(),
subscribe: listener => ctx.on('theme/change', listener),
},
},
async start() {
publish({ ...snapshot, loginVisible: true, loginFailed: false })
const transport = (globalThis as typeof globalThis & {
@@ -8,7 +8,7 @@ export const en = {
failureTitle: 'Could not sign in',
platformFailed: 'Could not complete the operation. Try again.', platformRetry: 'Retry',
loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness',
settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu',
settings: 'Settings', contactUs: 'Feedback', menu: 'Account menu',
nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in',
signIn: 'Sign in', signOut: 'Sign out', cancel: 'Cancel', open: 'Open browser',
initializing: 'Starting sign in…', waiting: 'Continue in your browser',
@@ -33,7 +33,7 @@ export const zh: Record<AccountKey, string> = {
failureTitle: '登录失败',
platformFailed: '操作未完成,请重试', platformRetry: '重试',
loading: '加载中…', backToHarness: '返回 DeepSeek Harness',
settings: '设置', contactUs: '联系我们', menu: '账号菜单',
settings: '设置', contactUs: '意见反馈', menu: '账号菜单',
nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录',
signIn: '登录', signOut: '退出登录', cancel: '取消', open: '打开浏览器',
initializing: '正在发起登录…', waiting: '请在浏览器中继续',
@@ -10,7 +10,7 @@ export interface Config {
}
/** Validate public questionnaire options. */
export const Config: z<Partial<Config>, Config> = z.object({
contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnhcHE4A6lQaQ3v0raCXmBAg'),
contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg'),
contactSource: z.string().default(''),
})
/** Bootstrap key containing no account credentials. */
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx'
import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx'
import type {} from '../src/client/index.ts'
@@ -10,21 +11,35 @@ import { en, zh, type AccountKey } from '../src/client/locales.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
const operations: AccountSectionInjected = {
/** One resolved theme snapshot per scheme; the slot's theme hook serves these in the application. */
const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({
preference: colorScheme, fontSize: 14,
active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0,
})
function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<AccountDetails>, platform?: PlatformBridge): AccountSectionInjected {
return {
...platform === undefined ? {} : { platform },
hooks: { account: {
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
subscribe: () => () => {},
} },
hooks: {
account: {
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
subscribe: () => () => {},
},
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
},
contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(),
refresh: vi.fn(() => Promise.resolve()),
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
}
}
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
const operations = operationsOf(state, details, platform)
// AccountSection consumes no global hooks; the slot supplies them in the application.
const globals = {} as GlobalStandardProps
render(<AccountSection {...globals} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
close={() => {}} t={key => key in copy ? copy[key as AccountKey] : key} />)
return operations
}
@@ -57,7 +72,8 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu',
cleanup()
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} signOut={signOut}
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
t={key => key in copy ? copy[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUs, copy.signOut])
@@ -86,14 +102,16 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
return Promise.reject(new Error('account start failed'))
})
const view = render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openOnboarding={() => {}} openSettings={() => {}}
t={key => key in en ? en[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: en.menu }))
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signIn })) })
expect(start).toHaveBeenCalledOnce()
expect(screen.queryByRole('alert')).toBeNull()
view.rerender(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openOnboarding={() => {}} openSettings={() => {}}
t={key => key in en ? en[key as AccountKey] : key} />)
expect(screen.getByRole('dialog').textContent).toContain(en.failed)
expect(screen.queryByRole('alert')).toBeNull()
@@ -108,7 +126,8 @@ it('keeps the sidebar alert for a failed sign-out', async () => {
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
signOut={() => Promise.reject(new Error('account sign-out failed'))}
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide
openOnboarding={() => {}} openSettings={() => {}} t={key => key in en ? en[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: en.menu }))
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signOut })) })
@@ -253,6 +272,7 @@ it.each([
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
})
@@ -264,6 +284,7 @@ it('shows the profile image in settings and the sidebar, with independent load-e
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
const images = document.querySelectorAll('img')
expect(images).toHaveLength(2)
@@ -324,12 +345,26 @@ it('opens more account information externally without invoking the embedded Plat
it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s and lets the user cancel the active attempt', async (phase) => {
const operations = mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase, authorizeUrl: 'https://example.test/authorize' } })
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize')
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize?theme=light')
expect(screen.getByRole('status').textContent).toBe(phase === 'initializing' ? en.initializing : phase === 'waiting-browser' ? en.waiting : en.completing)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.cancel })) })
expect(operations.cancel).toHaveBeenCalledExactlyOnceWith('attempt')
})
it('opens the authorization link with the active Desktop palette and follows later switches', () => {
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
const operations = operationsOf({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser', authorizeUrl } })
const element = (theme: ThemeSnapshot) => <AccountSection {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(theme)} close={() => {}} t={key => key in en ? en[key as AccountKey] : key} />
const view = render(element(themeOf('dark')))
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
view.rerender(element(themeOf('light')))
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
})
it.each(['failed', 'expired'] as const)('allows a new sign-in after the attempt is %s', (phase) => {
mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase } })
expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false)
@@ -354,7 +389,8 @@ it('reports a rejected settings login and disables login while initial state is
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: true }
const props = { ...({} as GlobalStandardProps), ...operations,
start: vi.fn(async () => { throw new Error('unavailable') }),
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), close: () => {},
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()), close: () => {},
t: (key: string) => en[key as AccountKey],
}
const view = render(<AccountSection {...props} />)
@@ -373,7 +409,9 @@ it('dismisses a collapsed menu and hands its login dialog to the API-key onboard
const openOnboarding = vi.fn()
let snapshot = operations.hooks.account.getSnapshot()
const props = { ...({} as GlobalStandardProps), ...operations, wide: false, openSettings: vi.fn(), openOnboarding,
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), t: (key: string) => en[key as AccountKey] }
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()),
t: (key: string) => en[key as AccountKey] }
const view = render(<AccountMenu {...props} />)
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe('')
fireEvent.click(screen.getByRole('button', { name: en.menu }))
@@ -5,6 +5,7 @@ import { ok } from '@deepseek-ai/dsh-remote-mock'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type { AccountDetails, AccountView, AccountUserId, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { AccountSectionInjected } from '../src/client/AccountSection.tsx'
import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts'
@@ -40,6 +41,16 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
const c = await start()
const actions = operations(c)
expect(c.ctx.slots.entries('settings.models.sign-in')[0]!.inject!()).toBe(actions)
// The account UI follows the live theme service through the framework hook channel.
const theme = c.ctx.get('theme') as ThemeRuntime
const onTheme = vi.fn()
const offTheme = actions.hooks.theme.subscribe(onTheme)
expect(actions.hooks.theme.getSnapshot()).toBe(theme.getTheme())
const probe = theme.register({ id: 'probe', colorScheme: 'dark', tokens: {} })
expect(onTheme).toHaveBeenCalledOnce()
probe()
offTheme()
expect(theme.getTheme().themes.map(candidate => candidate.id)).toEqual(['light', 'dark'])
await actions.refresh()
expect(c.mock.remote.account.getProfile).not.toHaveBeenCalled()
const listener = vi.fn()
@@ -52,7 +63,9 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
actions.showLogin(false)
expect(listener).toHaveBeenCalledTimes(2)
actions.contactUs()
expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.has('prefill_uid')).toBe(false)
const signedOut = new URL(String(open.mock.calls.at(-1)![0]))
expect(signedOut.searchParams.has('prefill_uid')).toBe(false)
expect(signedOut.searchParams.has('hide_uid')).toBe(false)
c.mock.remote.account.getProfile.mockResolvedValue(ok(profile))
c.mock.streams.push('account/watch', stored)
await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) })
@@ -62,7 +75,8 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
vi.spyOn(c.ctx.locale, 'getSnapshot').mockReturnValue({ ...c.ctx.locale.getSnapshot(), active: 'zh' })
actions.contactUs()
const support = new URL(String(open.mock.calls.at(-1)![0]))
expect(support.searchParams.get('prefill_uid')).toBe('account-user')
expect(support.searchParams.has('prefill_uid')).toBe(false)
expect(support.searchParams.has('hide_uid')).toBe(false)
expect(support.searchParams.get('prefill_app_locale')).toBe('zh-CN')
await c.unload(SELF)
expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0)
@@ -1,38 +1,42 @@
import { expect, it } from 'vitest'
import type { AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
import { Config } from '../src/contact-config.ts'
import { contactUrl } from '../src/client/contact-url.ts'
it('prefills available support context and hides all context fields', () => {
const config = Config({ contactSource: 'app_harness' })
const url = new URL(contactUrl(config, {
uid: 'user&123' as AccountUserId, version: '1.2.3', locale: 'zh-CN',
width: 1512, height: 982, pixelRatio: 2,
version: '1.2.3', locale: 'zh-CN', width: 1512, height: 982, pixelRatio: 2,
}))
expect(url.origin).toBe('https://trtgsjkv6r.feishu.cn')
expect(url.pathname).toBe('/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg')
expect(Object.fromEntries(url.searchParams)).toEqual({
hide_uid: '1', prefill_uid: 'user&123', hide_source: '1', prefill_source: 'app_harness',
hide_app_version: '1', prefill_app_version: '1.2.3', hide_os_version: '1',
hide_source: '1', prefill_source: 'app_harness', hide_app_version: '1', prefill_app_version: '1.2.3',
hide_os_version: '1',
hide_device_brand: '1', hide_device_model: '1', hide_app_locale: '1', prefill_app_locale: 'zh-CN',
hide_screen_resolution: '1', prefill_screen_resolution: '3024x1964',
})
expect(url.searchParams.has('prefill_uid')).toBe(false)
expect(url.searchParams.has('hide_uid')).toBe(false)
})
it('opens a configured form without stale account context when signed out', () => {
const url = new URL(contactUrl(Config({ contactFormUrl: 'https://example.test/form/?prefill_uid=old&prefill_source=old' }), {
uid: null, version: undefined, locale: 'en', width: 0, height: 0, pixelRatio: 1,
it('opens a configured form without stale context when unavailable', () => {
const url = new URL(contactUrl(Config({ contactFormUrl: 'https://example.test/form/?prefill_uid=old&hide_uid=1&prefill_app_version=old&prefill_source=old' }), {
version: undefined, locale: 'en', width: 0, height: 0, pixelRatio: 1,
}))
expect(url.origin).toBe('https://example.test')
expect(url.searchParams.has('prefill_uid')).toBe(false)
expect(url.searchParams.has('hide_uid')).toBe(false)
expect(url.searchParams.has('prefill_app_version')).toBe(false)
expect(url.searchParams.has('prefill_source')).toBe(false)
expect(url.searchParams.has('prefill_screen_resolution')).toBe(false)
expect(url.searchParams.get('hide_uid')).toBe('1')
expect(url.searchParams.get('hide_source')).toBe('1')
expect(url.searchParams.get('hide_app_version')).toBe('1')
expect(() => Config({ contactFormUrl: 'javascript:alert(1)' })).toThrow()
})
it.each([NaN, Infinity])('uses CSS pixel dimensions when device pixel ratio is %s', (pixelRatio) => {
const url = new URL(contactUrl(Config({}), {
uid: null, version: undefined, locale: 'en', width: 800, height: 600, pixelRatio,
version: undefined, locale: 'en', width: 800, height: 600, pixelRatio,
}))
expect(url.searchParams.get('prefill_screen_resolution')).toBe('800x600')
})
@@ -1 +1 @@
SettingsContact usSign out
SettingsFeedbackSign out
@@ -1 +1 @@
设置联系我们退出登录
设置意见反馈退出登录
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx'
import type { AccountSnapshot } from '../src/client/AccountSection.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -10,6 +11,11 @@ import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const theme: ThemeSnapshot = {
preference: 'light', fontSize: 14,
active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0,
}
it('claims onboarding while loading, offers API key fallback, and completes after login', () => {
let account: AccountSnapshot = { view: undefined, details: undefined, failed: false }
const unused = (): never => { throw new Error('AccountOnboarding does not consume global hooks') }
@@ -21,6 +27,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
...globals,
complete: vi.fn(), useApiKey: vi.fn(), setOnboarding: vi.fn(), showLogin: vi.fn(),
useAccount: <T,>(select: (snapshot: AccountSnapshot) => T) => select(account),
useTheme: <T,>(select: (snapshot: ThemeSnapshot) => T) => select(theme),
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), contactUs: vi.fn(), t: makeTranslate(en),
}
@@ -8,13 +8,16 @@ import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const id = 'login-attempt' as SignInAttemptId
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en) {
function dialogProps(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const start = vi.fn(async () => {})
const cancel = vi.fn(async () => {})
const close = vi.fn()
const useApiKey = vi.fn()
const props = { start, cancel, close, useApiKey, t: (key: keyof typeof en) => copy[key],
return { start, cancel, close, useApiKey, colorScheme, t: (key: keyof typeof en) => copy[key],
account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, failed: false } }
}
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const props = dialogProps(attempt, copy, colorScheme)
render(<SignInDialog {...props} />)
return props
}
@@ -57,7 +60,7 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
mount({ id, phase: 'waiting-browser', authorizeUrl }, copy)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
expect(writeText).toHaveBeenLastCalledWith(authorizeUrl)
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
act(() => { vi.advanceTimersByTime(1000) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copiedLink })) })
expect(writeText).toHaveBeenCalledTimes(2)
@@ -74,6 +77,25 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
}
})
it('copies the authorization link with the palette of the current render', async () => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn(async () => {})
try {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const attempt = { id, phase: 'waiting-browser' as const, authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=example' }
const props = dialogProps(attempt, en, 'dark')
const view = render(<SignInDialog {...props} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copyLink })) })
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
view.rerender(<SignInDialog {...props} colorScheme="light" />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copiedLink })) })
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
} finally {
if (clipboard) Object.defineProperty(navigator, 'clipboard', clipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
it.each([en, zh])('shows a temporary copy failure without interrupting sign-in', async (copy) => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
vi.useFakeTimers()
@@ -29,6 +29,9 @@
{
"path": "../ui-slots"
},
{
"path": "../ui-theme/tsconfig.client.json"
},
{
"path": "../../credentials/deepseek-account"
},
@@ -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-sidebar-documentpreview/README.md
README.md: 554aae41691148602b6fe563d802d0215adf2035
README.zh.md: f7503f901311649adf161b8dcad07e73267d83d2
README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8
README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8
@@ -61,12 +61,12 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
<a id="excel-preview"></a>
## Excel preview
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service.
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service.
XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate.
@@ -84,6 +84,8 @@ The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS,
The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency.
The pinned [ExcelJS patch](../../../patches/[email protected]) resolves the workbook, styles, shared strings, worksheets, comments, Tables, and VML through package relationships, including absolute and relative targets and ASCII case-equivalent part names, and recognizes SpreadsheetML and VML names by namespace URI. Strict OOXML SpreadsheetML and relationship URIs map to the same supported preview features; this is not full Strict conformance. XML parts accept UTF-8 and either byte order of UTF-16; CDATA contributes literal text. Drawing and conditional-format notices follow relationships regardless of part directories. Unreferenced `xl/drawings/*.xml` parts and their relationship files are also omitted, without adding notices; comment VML remains. Missing referenced parts and ambiguous case-equivalent ZIP entries fail the preview. Comments and Table metadata survive parsing but have no dedicated preview controls. The patch covers the Node sources and `dist/exceljs.js`; its browser entry selects that patched bundle. Dependency upgrades must preserve both entry paths and pass the [independent-writer regressions and fuzz diagnostics](tests/fuzz/README.md).
<a id="office-preview"></a>
## Office preview
@@ -61,12 +61,12 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
<a id="excel-preview"></a>
## Excel 预览
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。
XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
@@ -84,6 +84,8 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
固定版本的 [ExcelJS 补丁](../../../patches/[email protected])通过包内关系解析工作簿、样式、共享字符串、工作表、批注、Table 和 VML,支持绝对及相对目标,以及 ASCII 大小写等价的部件名,并按命名空间 URI 识别 SpreadsheetML 和 VML 名称。Strict OOXML 的 SpreadsheetML 和关系 URI 映射到相同的已支持预览功能;这不代表完整支持 Strict 标准。XML 部件支持 UTF-8 及两种字节序的 UTF-16;CDATA 按字面文本读取。绘图和条件格式提示按关系查找内容,不依赖部件目录。未被引用的 `xl/drawings/*.xml` 部件及其关系文件也会被移除,但不会产生提示;批注 VML 保持不变。缺失引用部件或存在大小写等价的歧义 ZIP 条目会使预览失败。解析保留批注和 Table 元数据,但预览器没有专门展示它们的控件。补丁同时覆盖 Node 源码和 `dist/exceljs.js`,浏览器入口选择已修补的 bundle。升级依赖时必须保留两个入口的行为,并通过[独立写入器回归测试和 fuzz 诊断](tests/fuzz/README.zh.md)。
<a id="office-preview"></a>
## Office 预览
@@ -43,6 +43,7 @@
.renderer .code pre {
box-sizing: border-box;
padding: 16px;
min-width: 100%;
overflow: visible;
white-space: pre;
@@ -24,9 +24,11 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
code={content.text}
lang={language}
streaming={!content.eof}
wrap={wrap}
lineNumbers
copyLabel={t('copy')}
copiedLabel={t('copied')}
toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
/>
</div>
)
@@ -1,5 +1,5 @@
/** Read-only spreadsheet surface backed by browser-parsed workbook data. */
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Workbook } from '@fortune-sheet/react'
import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import fortuneCss from '@fortune-sheet/react/dist/index.css?inline'
@@ -14,6 +14,7 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }`
/**
* Display stored spreadsheet values and formats without editing or recalculation.
* Keep the mounted workbook sized to its preview pane.
* @param props - Complete workbook bytes, limits, and locale.
* @returns An isolated spreadsheet surface with cancellable loading.
*/
@@ -21,6 +22,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
const data = content.kind === 'bytes' ? content.data : undefined
const [state, setState] = useState<State>()
const [attempt, setAttempt] = useState(0)
const workbookRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (data === undefined) return
const controller = new AbortController()
@@ -31,6 +33,14 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
)
return () => { controller.abort() }
}, [data, format, limits, attempt])
useEffect(() => {
const element = workbookRef.current
if (element === null || typeof ResizeObserver === 'undefined') return
// FortuneSheet measures its canvas on window resize, but pane resizing does not emit that event.
const observer = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')) })
observer.observe(element)
return () => { observer.disconnect() }
}, [state])
if (data === undefined) return <p className={css.status} role="alert">{t('invalid')}</p>
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
<StateDot state="ongoing" />
@@ -46,7 +56,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
<IconWarningTriangleOutlineRegular size={16} />
<span>{t('unsupportedNotice', { features: state.value.unsupportedFeatures.map(feature => t(feature)).join(t('featureSeparator')) })}</span>
</div>}
<div className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
<div ref={workbookRef} className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
{hasFormulas && <Tooltip portal label={t('formulaWarning')} side="bottom" delayMs={500}>
<button type="button" className={css.formulaWarning} aria-label={t('formulaWarning')} data-excel-formula-warning>
<IconWarningTriangleOutlineRegular size={14} />
@@ -1,5 +1,5 @@
/** XLSX preview copies without DrawingML parts; worksheet XML and source bytes remain untouched. */
import { strFromU8, unzipSync, zipSync, type Unzipped } from 'fflate'
import { unzipSync, zipSync, type Unzipped } from 'fflate'
import { XMLParser } from 'fast-xml-parser'
import type { ExcelUnsupportedFeature } from './model.ts'
@@ -8,13 +8,21 @@ export class XlsxPreviewArchive {
/** Detected workbook content that the preview does not display. */
readonly unsupportedFeatures = new Set<ExcelUnsupportedFeature>()
private readonly files: Unzipped
private readonly parts = new Map<string, Uint8Array>()
/**
* Read an archive without modifying the borrowed source buffer.
* @param bytes - Complete XLSX source bytes.
* @throws When ZIP entries have ASCII case-equivalent names.
*/
constructor(private readonly bytes: Uint8Array<ArrayBuffer>) {
this.files = unzipSync(bytes)
for (const [path, bytes] of Object.entries(this.files)) {
if (path.endsWith('/')) continue
const key = partKey(path)
if (this.parts.has(key)) throw new Error(`Ambiguous XLSX part: ${path}`)
this.parts.set(key, bytes)
}
}
/**
@@ -23,18 +31,36 @@ export class XlsxPreviewArchive {
*/
withoutDrawings(): Uint8Array<ArrayBuffer> {
const entries = Object.entries(this.files)
const retained = entries.filter(([path, bytes]) => {
if (/^xl\/worksheets\/[^/]+\.xml$/.test(path)) {
this.inspectContent(bytes)
return true
}
if (/^xl\/drawings\/[^/]+\.xml$/.test(path)) {
this.inspectContent(bytes)
} else if (!/^xl\/drawings\/_rels\/[^/]+\.xml\.rels$/.test(path)) {
return true
}
return false
})
const omitted = new Set<string>()
for (const [path, bytes] of entries) {
// ExcelJS also discovers unreferenced drawings in its conventional directory.
if (/^xl\/drawings\/(?:[^/]+\.xml|_rels\/[^/]+\.xml\.rels)$/i.test(path)) omitted.add(partKey(path))
const match = /^(.*\/)?_rels\/([^/]+)\.rels$/i.exec(path)
if (match === null) continue
const owner = `${match[1] ?? ''}${match[2]}`
new XMLParser({
removeNSPrefix: true, ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: false,
updateTag: (tag: string, _path, attributes: Record<string, unknown>) => {
const type = typeof attributes.Type === 'string'
? attributes.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/') : undefined
if (tag !== 'Relationship' || attributes.TargetMode === 'External') return tag
const drawing = type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing'
if (!drawing && type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') return tag
if (typeof attributes.Target !== 'string') throw new Error('Missing XLSX relationship target')
const target = resolvePart(owner, attributes.Target)
const content = this.parts.get(partKey(target))
if (content === undefined) throw new Error(`Missing XLSX part: ${target}`)
this.inspectContent(content)
if (drawing) {
omitted.add(partKey(target))
const split = target.lastIndexOf('/')
omitted.add(partKey(`${target.slice(0, split + 1)}_rels/${target.slice(split + 1)}.rels`))
}
return tag
},
}).parse(decodeXml(bytes))
}
const retained = entries.filter(([path]) => !omitted.has(partKey(path)))
return retained.length === entries.length ? this.bytes : new Uint8Array(zipSync(Object.fromEntries(retained), { level: 0 }))
}
@@ -52,6 +78,33 @@ export class XlsxPreviewArchive {
if (tag === 'sheetData') return false
return tag
},
}).parse(strFromU8(bytes))
}).parse(decodeXml(bytes))
}
}
/** OPC compares part names using ASCII case equivalence, leaving ZIP names intact. */
function partKey(path: string): string {
return path.replace(/[A-Z]/g, character => character.toLowerCase())
}
/** Decode the mandatory XML encodings without rewriting the archived bytes. */
function decodeXml(bytes: Uint8Array): string {
const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
? 'utf-16le'
: (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
? 'utf-16be' : 'utf-8'
return new TextDecoder(encoding, { fatal: true }).decode(bytes)
}
/** Resolve internal relationship targets without accepting traversal above the package root. */
function resolvePart(owner: string, target: string): string {
const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1)
for (const segment of target.split('/')) {
if (segment === '' || segment === '.') continue
if (segment === '..') {
if (parts.length === 0) throw new Error('XLSX relationship escapes package')
parts.pop()
} else parts.push(segment)
}
return parts.join('/')
}
@@ -24,9 +24,12 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd
const copyLabel = t('code.copy')
const copiedLabel = t('code.copied')
const footnotes = t('footnotes')
const codeLabel = t('codeBlock.title')
const wrapLabel = t('codeBlock.wrap')
const unwrapLabel = t('codeBlock.unwrap')
const labels = useMemo<MarkdownLabels>(() => ({
code: { copyLabel, copiedLabel }, footnotes,
}), [copyLabel, copiedLabel, footnotes])
code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes,
}), [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel])
if (content.kind !== 'text') return null
return (
<div className={css.document} data-document-markdown>
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { CodeBody } from '../src/client/code/CodeBody.tsx'
@@ -84,7 +84,11 @@ describe('CodeBody', () => {
expect(view.getByText(language, { exact: true })).toBeTruthy()
expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
const copy = view.getByRole('button', { name: 'Copy' })
expect(copy.textContent).toBe('')
expect(view.getAllByRole('button')).toHaveLength(1)
fireEvent.mouseEnter(copy)
expect(view.getByRole('tooltip').textContent).toBe('Copy')
})
it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
@@ -155,6 +159,7 @@ describe('CodeBody', () => {
const view = render(<CodeBody {...props(content)} />)
const pre = element(view.container, '.shiki')
const block = element(view.container, '.md-code-block')
expect(block.getAttribute('data-code-wrap')).toBe('false')
const scrollport = element(view.container, '[data-code-block-content]')
expect(getComputedStyle(block).marginTop).toBe('0px')
expect(getComputedStyle(block).position).toBe('static')
@@ -165,6 +170,8 @@ describe('CodeBody', () => {
expect(getComputedStyle(pre).overflow).toBe('visible')
expect(getComputedStyle(pre).wordBreak).toBe('normal')
view.rerender(<CodeBody {...props(content, { wrap: true })} />)
expect(block.getAttribute('data-code-wrap')).toBe('true')
expect(view.getAllByRole('button')).toHaveLength(1)
expect(element(view.container, '.shiki')).toBe(pre)
expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Excel preview lifecycle and read-only renderer settings. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { Config } from '../src/config.ts'
@@ -17,7 +17,31 @@ const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: C
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0, unsupportedFeatures: [] }
const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1, unsupportedFeatures: [] }
afterEach(() => { cleanup(); vi.resetAllMocks() })
afterEach(() => { cleanup(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('refreshes the existing workbook on pane resize and disconnects on file replacement and unmount', async () => {
const observers: { callback: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = []
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) { observers.push({ callback, observe: this.observe, disconnect: this.disconnect }) }
})
const dispatch = vi.spyOn(window, 'dispatchEvent')
mocked.parse.mockResolvedValue(value)
const view = render(<ExcelBody {...loadedProps} />)
await waitFor(() => { expect(observers).toHaveLength(1) })
const first = observers[0]!
expect(first.observe).toHaveBeenCalledWith(view.container.querySelector('[data-excel-preview] > div'))
act(() => { first.callback([], {} as ResizeObserver) })
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'resize' }))
expect(mocked.workbook).toHaveBeenCalledOnce()
expect(mocked.parse).toHaveBeenCalledOnce()
view.rerender(<ExcelBody {...loadedProps} content={{ kind: 'bytes', data: new Uint8Array([2]) }} />)
await waitFor(() => { expect(observers).toHaveLength(2) })
expect(first.disconnect).toHaveBeenCalledOnce()
view.unmount()
expect(observers[1]!.disconnect).toHaveBeenCalledOnce()
})
it('shows loading then a workbook with editing and recalculation disabled', async () => {
mocked.parse.mockResolvedValue(formulaValue)
@@ -0,0 +1,14 @@
/** Independent-writer XLSX files shared by parser and shipped-browser regressions. */
import { readFile } from 'node:fs/promises'
/** Five isolated failures and one workbook combining their features. */
export const excelOpcCases = ['comments', 'table', 'prefix', 'part-name', 'relative', 'combined'] as const
/**
* Read an Openpyxl or XlsxWriter fixture without regenerating it with the parser under test.
* @param name - Retained fixture name.
* @returns Complete XLSX bytes owned by the caller.
*/
export async function excelOpcFixture(name: typeof excelOpcCases[number]): Promise<Uint8Array<ArrayBuffer>> {
return new Uint8Array(await readFile(new URL(`./fixtures/excel-opc/${name}.xlsx`, import.meta.url)))
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more