diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml index 25c1856d60..f71a656550 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md -2026-09-14-deepseek-account-login.md: 804e5a2c180a866dc096cacef75315af1172efd3 -2026-09-14-deepseek-account-login.zh.md: 6e8344b0281c113be89f2dac68fa8f7d2c08830a +2026-09-14-deepseek-account-login.md: 165cd2d0a2fbe3a4a71d3ce31de69c5005c639b2 +2026-09-14-deepseek-account-login.zh.md: 5e09c0a17cc933b46aa6498c70d86fdefd26d21c diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md index 804e5a2c18..165cd2d0a2 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md index 6e8344b028..5e09c0a17c 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -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。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.i18n.yaml new file mode 100644 index 0000000000..4f16b7775b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md new file mode 100644 index 0000000000..3fd42b77d4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md new file mode 100644 index 0000000000..88d4e2deef --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md @@ -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 降级到静默层:后端只在启动时安装一次提示符,无法重新设定。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5567a0c681..862299b9a2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -143,6 +143,7 @@ pnpm applies local patches to the following packages at install time, so shipped - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) ## Official Claude Code platform payloads diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index cd047ea696..7dbf8777b4 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2a7cc8a35b5774fa3cf0c77ec95b9f16df4c24b7 -README.zh.md: 4f00d7f8240ab8d10679805679f4042b08ef4c59 +README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1 +README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2a7cc8a35b..cb10794ae6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -407,7 +407,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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4f00d7f824..778fe9c6bc 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -407,7 +407,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 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e13cc34ef..d9c55f44ae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { 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 { 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 } diff --git a/apps/desktop/src/preload-theme.ts b/apps/desktop/src/preload-theme.ts index cb1acb9699..5091984fdb 100644 --- a/apps/desktop/src/preload-theme.ts +++ b/apps/desktop/src/preload-theme.ts @@ -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() } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 13ef5ed9f0..94409491f6 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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) => 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['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}`) +}) diff --git a/apps/desktop/tests/preload-theme.client.spec.ts b/apps/desktop/tests/preload-theme.client.spec.ts new file mode 100644 index 0000000000..2f0da9b97a --- /dev/null +++ b/apps/desktop/tests/preload-theme.client.spec.ts @@ -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() +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') +}) diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index 1926d811ee..1022b3a8b8 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -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() + const listeners = new Map void>>() + const observers: Array<() => void> = [] + const document = { + readyState: 'loading', + documentElement: { + dataset: {} as Record, + 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>() 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) => { 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') diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 37c2416e9c..20a5a441f4 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -33,6 +33,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', () => ({ @@ -69,7 +70,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() }, } }, @@ -184,11 +185,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() diff --git a/apps/web/tests/excel-opc.e2e.ts b/apps/web/tests/excel-opc.e2e.ts new file mode 100644 index 0000000000..a3a49e3248 --- /dev/null +++ b/apps/web/tests/excel-opc.e2e.ts @@ -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 + + 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']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3c7faf36b5..70c0f7c748 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,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", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 99bdb6abba..29352e0c28 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b2a50d5e0c3b19bf69ec128272d318d7bd8d4b14 -config-catalog.zh.md: 96de23e9488a8f7c008aed0f851d89a02e9888b0 +config-catalog.md: 0a950288f7de0a283b1403621f705a3985b7176a +config-catalog.zh.md: ec248077cc3623788588bdcb6a6899debf9cafb9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b2a50d5e0c..0a950288f7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 96de23e948..ec248077cc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 608eb08bdb..be305142bc 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -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' diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 08438ffbb0..5b10d61e19 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: 2800f077cf55bc1663d28523d0d104d320235f4e -README.zh.md: ed0dbb07d64cf9fb2dd42395d27c15d8e8416230 +README.md: 2eb0f56daec2ad66c1407e397e30a872bda8c52e +README.zh.md: f1c1a5b66327d9dee85536afb737efd2afa23acb diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2800f077cf..2eb0f56dae 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -32,7 +32,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. @@ -50,7 +50,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. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index ed0dbb07d6..f1c1a5b663 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -32,7 +32,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 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。 @@ -50,7 +50,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 编辑器。 账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index c2701b6f22..6ebc0756ed 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -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", diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index ad93953695..2ad41b76a3 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -17,9 +17,10 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' * @returns account menu launcher. */ export function AccountMenu({ - wide, openSettings, openOnboarding, useAccount, signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t, + wide, openSettings, openOnboarding, useAccount, useTheme, signOut, hasRunningAccountTasks, 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 expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' const [expiryNotice, setExpiryNotice] = useState(false) @@ -59,7 +60,8 @@ export function AccountMenu({ else if (id === 'signin') beginSignIn() else void requestSignOut() }} /> - {account.loginVisible && !account.onboarding && { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />} {signedIn && signOutImpact !== undefined && { setSignOutImpact(undefined) }} t={t} />} diff --git a/packages/client/ui-settings-account/src/client/AccountOnboarding.tsx b/packages/client/ui-settings-account/src/client/AccountOnboarding.tsx index 8dfcb08c48..e07c81c6db 100644 --- a/packages/client/ui-settings-account/src/client/AccountOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/AccountOnboarding.tsx @@ -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) { 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 { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} /> } diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index c6ef8bf366..e2d545ae62 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -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 } + /** Account stream owned by the Host and theme snapshots published by the renderer, observed through framework hooks. */ + hooks: { + account: HostObservable + /** Palette the Platform login pages follow. */ + theme: HostObservable + } /** @returns after account details are refreshed; concurrent refreshes share a request. */ refresh: () => Promise - /** 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 @@ -53,8 +59,9 @@ export interface AccountSectionInjected { export type AccountSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace /** @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) @@ -109,7 +116,8 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform } {active &&
- {attempt.authorizeUrl && + {attempt.authorizeUrl && {t('open')} }