Merge origin/master into feat/workspace-dependencies-package

This commit is contained in:
fz committed 2026-09-18 15:27:45 +08:00
commit 32524892a3
39 files changed
+510 -115

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-08-25-session-observations-and-projection-owned-client-state.md
2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549
2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91
@@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an
### Domain applications
- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing.
- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection.
- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header.
- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session.
- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself.
@@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
### 领域应用
- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。
- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。
- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。
- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。
- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。
+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: a798368dc5c94b0110251bd333951b6a221028dd
README.zh.md: b4d14727e16ddccb4b96c41fa8158bfc9f66d531
README.md: 3cad6155a0cba43decd2854c73a256fe0c50e6f2
README.zh.md: c29a0f6cc21f5fe76cd5bbfce7c880f6eb968bbd
+2 -2
View File
@@ -263,7 +263,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
## Updates
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
@@ -295,7 +295,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
+2 -2
View File
@@ -264,7 +264,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
## 更新
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
@@ -296,7 +296,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="update-dialog.css">
<link rel="stylesheet" href="mandatory-update.css">
</head>
<body>
<body class="update-dialog">
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
<header><h1 id="title"></h1></header>
<p id="detail"></p>
@@ -7,6 +7,8 @@ const element = id => document.getElementById(id)
const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match)
function render(view) {
document.body.classList.toggle('visible', view.policy.blocking)
if (!view.policy.blocking) return
const wasConfirming = current?.confirmation !== undefined
current = view
const { locale: { id, messages }, policy, update, confirmation, navigation } = view
+6 -1
View File
@@ -5,7 +5,8 @@
background: transparent;
}
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; display: grid; place-items: center; background: rgb(0 0 0 / 24%); }
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
main:focus { outline: none; }
@@ -24,3 +25,7 @@ summary { cursor: pointer; color: #5b6472; }
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
button:disabled { opacity: 0.5; cursor: default; }
[hidden] { display: none !important; }
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
body.update-dialog.visible { opacity: 1; }
@starting-style { body.update-dialog.visible { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
+1 -1
View File
@@ -7,7 +7,7 @@
<title></title>
<link rel="stylesheet" href="update-dialog.css">
</head>
<body>
<body class="update-dialog">
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
<p id="detail"></p>
+18 -3
View File
@@ -5,7 +5,7 @@ let responding = false
function respond(index) {
if (responding || view === undefined) return
responding = true
void api.respond(index).catch(() => { responding = false })
void api.respond(view.revision, index).catch(() => { responding = false })
}
document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) })
document.addEventListener('keydown', event => {
@@ -18,7 +18,14 @@ document.addEventListener('keydown', event => {
event.preventDefault()
controls[next].focus()
})
void api.status().then(state => {
function render(state) {
if (state === null) {
responding = true
document.body.classList.remove('visible')
return
}
if (view !== undefined && state.revision <= view.revision) return
responding = false
view = state
document.documentElement.lang = state.locale
document.title = state.title
@@ -29,6 +36,8 @@ void api.status().then(state => {
document.getElementById('technical-details').hidden = state.technicalDetails === ''
document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel
document.getElementById('technical-details-content').textContent = state.technicalDetails
document.getElementById('technical-details').open = false
document.getElementById('actions').replaceChildren()
for (const [index, label] of state.buttons.entries()) {
const button = document.createElement('button')
button.type = 'button'
@@ -38,5 +47,11 @@ void api.status().then(state => {
document.getElementById('actions').append(button)
}
document.querySelector('main').hidden = false
document.getElementById('dialog').scrollTop = 0
document.body.classList.add('visible')
document.getElementById('dialog').focus()
})
}
let received = false
const unsubscribe = api.subscribe(state => { received = true; render(state) })
window.addEventListener('pagehide', unsubscribe, { once: true })
void api.status().then(state => { if (!received) render(state) })
+39 -32
View File
@@ -474,43 +474,50 @@ async function main(): Promise<void> {
if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
return
}
let state = updates.state
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
const controller = new AbortController()
ordinaryDialogs.add(controller)
const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
try {
let controller: AbortController | undefined
let progress: Promise<unknown> | undefined
try {
let state = updates.state
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
controller = new AbortController()
ordinaryDialogs.add(controller)
progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
if (!joinedPolicyAuthentication) {
void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
}
state = await updateSchedule.check(true)
} finally { controller.abort(); ordinaryDialogs.delete(controller); await progress }
}
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
if (state.version !== undefined) {
failedOperation = 'install'
await showUpdateFailure(await updates.install(state.version))
}
return
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
if (!isMandatory() && state.version !== undefined) {
failedOperation = 'download'
await showUpdateFailure(await downloadUpdate(state.version))
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
if (state.version !== undefined) {
failedOperation = 'install'
await showUpdateFailure(await updates.install(state.version))
}
return
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
if (!isMandatory() && state.version !== undefined) {
controller?.abort()
failedOperation = 'download'
await showUpdateFailure(await downloadUpdate(state.version))
}
} finally {
controller?.abort()
if (controller !== undefined) ordinaryDialogs.delete(controller)
await progress
}
}).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation,
message: desktopErrorState(error).message }))
+14 -2
View File
@@ -49,6 +49,7 @@ const page = 'dsh-app://shell/mandatory-update.html'
/** A modal child blocks the product window without cancelling work in the Host. */
export class DesktopMandatoryUpdateWindow {
private window: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
private error: string | undefined
private action: Promise<void> | undefined
@@ -142,11 +143,20 @@ export class DesktopMandatoryUpdateWindow {
this.clearNavigation()
this.restart = undefined
this.deferred = false
this.window?.destroy()
this.window = undefined
if (this.window !== undefined && this.closing === undefined) {
const window = this.window
window.webContents.send(MANDATORY_IPC.state, this.view())
this.closing = setTimeout(() => {
this.closing = undefined
if (this.window === window) this.window = undefined
window.destroy()
}, 150)
}
this.error = undefined
return
}
clearTimeout(this.closing)
this.closing = undefined
if (this.navigationUrl !== this.options.policy().page) this.clearNavigation()
if (this.options.update().phase === 'error') this.restart = undefined
if (this.window === undefined) {
@@ -186,6 +196,8 @@ export class DesktopMandatoryUpdateWindow {
/** Detach IPC and release the modal during application shutdown. */
dispose(): void {
this.disposed = true
clearTimeout(this.closing)
this.closing = undefined
this.finishConfirmation(false)
this.attention.reset()
this.clearNavigation()
+7 -2
View File
@@ -3,7 +3,12 @@ import { contextBridge, ipcRenderer } from 'electron'
import { UPDATE_DIALOG_IPC, type UpdateDialogApi, type UpdateDialogView } from './update-dialog.ts'
const api: UpdateDialogApi = {
status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise<UpdateDialogView>,
respond: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise<void>,
status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise<UpdateDialogView | null>,
respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise<void>,
subscribe: (listener) => {
const receive = (_event: Electron.IpcRendererEvent, view: UpdateDialogView | null): void => { listener(view) }
ipcRenderer.on(UPDATE_DIALOG_IPC.changed, receive)
return () => { ipcRenderer.removeListener(UPDATE_DIALOG_IPC.changed, receive) }
},
}
if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api)
+52 -23
View File
@@ -4,10 +4,12 @@ import type { DesktopLocale } from './locale.ts'
import { createUpdateOverlay } from './update-overlay.ts'
/** Channels available only to the isolated update-dialog document. */
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', respond: 'dsh-update-dialog:respond' } as const
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
/** Text and choices supplied by the main process, never by product documents. */
export interface UpdateDialogView {
/** Identifies the displayed choices; a response from an older prompt is rejected. */
readonly revision: number
readonly locale: string
readonly title: string
readonly message: string
@@ -26,17 +28,22 @@ export interface UpdateDialogOptions extends MessageBoxOptions {
/** The document can select only a displayed response index. */
export interface UpdateDialogApi {
status(): Promise<UpdateDialogView>
respond(index: number): Promise<void>
status(): Promise<UpdateDialogView | null>
respond(revision: number, index: number): Promise<void>
subscribe(listener: (view: UpdateDialogView | null) => void): () => void
}
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/update-dialog.html'
/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */
/** One fading backdrop with replaceable confirmation content; aborted checks and mandatory policy cancel ordinary prompts. */
export class DesktopUpdateDialog {
private disposed = false
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => void } | undefined
private revision = 0
private window: BrowserWindow | undefined
private parent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
/** Focus the current explanation or confirmation without replacing it or granting permission. */
focus(): void { this.active?.window.focus() }
@@ -46,9 +53,11 @@ export class DesktopUpdateDialog {
* @param locale - Shell-owned copy.
*/
constructor(private readonly preload: string, private readonly locale: DesktopLocale) {
ipcMain.handle(UPDATE_DIALOG_IPC.status, event => this.owned(event).view)
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => {
const active = this.owned(event)
ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null })
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => {
this.owned(event)
const active = this.active
if (active === undefined || revision !== active.view.revision) throw new Error('desktop update: stale dialog response')
if (typeof index !== 'number' || !Number.isInteger(index)
|| (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) {
throw new Error('desktop update: invalid dialog response')
@@ -60,34 +69,45 @@ export class DesktopUpdateDialog {
/**
* @param parent - Window blocked by this confirmation.
* @param options - Main-owned localized content, response choices, and optional cancellation signal.
* @returns A displayed response, or the cancel response when closed, replaced, aborted, or unable to load.
* @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently.
*/
show(parent: BrowserWindow, options: UpdateDialogOptions): Promise<MessageBoxReturnValue> {
this.cancel()
const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge]
const cancelId = options.cancelId ?? buttons.length - 1
if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) {
return Promise.resolve({ response: cancelId, checkboxChecked: false })
}
const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle)
const view: UpdateDialogView = { locale: this.locale.id, title: options.title ?? '', message: options.message,
if (this.parent !== parent) { this.cancel(); this.close() }
this.active?.finish(this.active.view.cancelId, true)
clearTimeout(this.closing)
this.closing = undefined
const existing = this.window
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false)
this.window = window
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose,
technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails }
return new Promise((resolve) => {
const abort = (): void => { finish(cancelId) }
const finish = (response: number): void => {
if (this.active?.window !== window) return
const finish = (response: number, retain = false): void => {
if (this.active?.view !== view) return
this.active = undefined
options.signal?.removeEventListener('abort', abort)
if (!window.isDestroyed()) window.destroy()
if (!retain && !window.isDestroyed()) {
window.webContents.send(UPDATE_DIALOG_IPC.changed, null)
this.closing = setTimeout(() => { this.close() }, 150)
}
resolve({ response, checkboxChecked: false })
}
this.active = { window, view, finish }
options.signal?.addEventListener('abort', abort, { once: true })
window.once('closed', abort)
if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } }
window.once('closed', failed)
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
window.webContents.once('render-process-gone', abort)
void window.loadURL(page).catch(abort)
window.webContents.once('render-process-gone', failed)
void window.loadURL(page).catch(failed)
})
}
@@ -99,16 +119,25 @@ export class DesktopUpdateDialog {
if (this.disposed) return
this.disposed = true
this.cancel()
this.close()
ipcMain.removeHandler(UPDATE_DIALOG_IPC.status)
ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond)
}
private owned(event: IpcMainInvokeEvent): NonNullable<DesktopUpdateDialog['active']> {
const active = this.active
if (active === undefined || event.sender !== active.window.webContents
|| event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) {
private close(): void {
clearTimeout(this.closing)
this.closing = undefined
const window = this.window
this.window = undefined
this.parent = undefined
if (window !== undefined && !window.isDestroyed()) window.destroy()
}
private owned(event: IpcMainInvokeEvent): void {
const window = this.window
if (window === undefined || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) {
throw new Error('desktop update: rejected unowned dialog renderer')
}
return active
}
}
+15 -3
View File
@@ -5,15 +5,27 @@ import { BrowserWindow } from 'electron'
* @param parent - Product window whose content is blocked while the overlay is open.
* @param preload - Isolated shell-only preload.
* @param title - Localized window title.
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
* @returns A transparent child that follows its parent's content bounds and releases its listeners on close.
*/
export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string): BrowserWindow {
export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
parent, modal: true, show: false, frame: false, transparent: true,
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
skipTaskbar: true, hasShadow: false, title,
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
// macOS native modals animate the entire viewport as a sheet.
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
if (!nativeModal && process.platform === 'darwin') {
parent.on('focus', focus)
parent.webContents.on('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
parent.webContents.off('before-input-event', blockInput)
})
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
@@ -39,7 +51,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl
/** A native Windows modal retains its own title bar while the product window remains blocked. */
export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string,
platform: NodeJS.Platform = process.platform): BrowserWindow {
if (platform !== 'win32') return createUpdateOverlay(parent, preload, title)
if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false)
const window = new BrowserWindow({
parent, modal: true, show: false, title,
width: 640, height: 560, minWidth: 480, minHeight: 360,
+2
View File
@@ -73,12 +73,14 @@ export async function qualifyUpdateDialogs(root, fixture) {
const name = active ? 'update-active-tasks.png' : 'update-ready.png'
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
screenshots.push(name)
const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) })
if (active) {
await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined")
} else {
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
}
const result = await pending
await closed
assert.equal(window.isDestroyed(), true)
assert.equal(result.phase, active ? 'installing' : 'ready')
assert.equal(f.installations.length, active ? 1 : 0)
+7 -2
View File
@@ -289,7 +289,7 @@ beforeEach(() => {
vi.spyOn(console, 'info').mockImplementation(() => {})
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: 'desktop-test-resources' })
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
@@ -812,6 +812,8 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
await vi.advanceTimersByTimeAsync(20_000)
expect(modal.isDestroyed()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(modal.isDestroyed()).toBe(true)
expect(host.stop).not.toHaveBeenCalled()
expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' })
@@ -826,7 +828,10 @@ describe('desktop main startup', () => {
harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => {
checking.resolve(signal)
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
}).mockResolvedValueOnce({ response: 0 })
}).mockImplementationOnce(() => {
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false)
return Promise.resolve({ response: 0 })
})
const submenu = applicationMenuItems()
const action = submenu.find(item => item.label === 'Check for Updates…')
expect(action?.click).toBeTypeOf('function')
@@ -24,7 +24,7 @@ function fakeWindow() {
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false,
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
}
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear() })
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup() {
window = fakeWindow()
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
@@ -130,3 +130,25 @@ it('exits the application when the mandatory window is closed without clearing t
expect(native.quit).toHaveBeenCalledOnce()
expect(f.view().policy.blocking).toBe(true)
})
it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => {
vi.useFakeTimers()
const f = setup()
f.policy({ blocking: false, checking: false })
expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state,
f.view())
vi.advanceTimersByTime(100)
expect(window.destroy).not.toHaveBeenCalled()
f.policy({ blocking: true, checking: false })
vi.advanceTimersByTime(150)
expect(window.destroy).not.toHaveBeenCalled()
expect(window.loadURL).toHaveBeenCalledOnce()
f.policy({ blocking: false, checking: false })
vi.advanceTimersByTime(150)
expect(window.destroy).toHaveBeenCalledOnce()
expect(ui!.confirmationWindow).toBeUndefined()
f.policy({ blocking: true, checking: false })
f.policy({ blocking: false, checking: false })
ui!.dispose()
expect(vi.getTimerCount()).toBe(0)
})
+62 -3
View File
@@ -10,11 +10,13 @@ const fixture = await vi.hoisted(async () => {
class FakeWindow extends EventEmitter {
destroyed = false
readonly webContents = Object.assign(new EventEmitter(), {
send: vi.fn(),
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
setWindowOpenHandler: vi.fn(),
insertCSS: vi.fn(async () => 'blur'),
removeInsertedCSS: vi.fn(async () => {}),
})
readonly focus = vi.fn()
readonly show = vi.fn()
readonly setBounds = vi.fn()
readonly loadURL = vi.fn(async () => {})
@@ -33,6 +35,7 @@ vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: {
let dialogs: DesktopUpdateDialog | undefined
afterEach(() => {
vi.useRealTimers()
dialogs?.dispose()
dialogs = undefined
for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy()
@@ -48,7 +51,9 @@ function setup() {
})
const invoke = (channel: string, ...args: unknown[]) => {
const window = fixture.windows.at(-1)!
return fixture.handlers.get(channel)!({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, ...args)
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args))
}
return { parent, show, invoke }
}
@@ -73,7 +78,7 @@ it('follows the parent geometry and removes listeners when closed or replaced',
const f = setup()
const first = f.show()
const window = fixture.windows.at(-1)!
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: true, width: 900, height: 650 })
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 })
window.emit('ready-to-show')
expect(window.show).toHaveBeenCalledOnce()
f.parent.emit('resize')
@@ -85,7 +90,7 @@ it('follows the parent geometry and removes listeners when closed or replaced',
expect((await next).response).toBe(1)
expect(f.parent.listenerCount('resize')).toBe(0)
expect(f.parent.listenerCount('move')).toBe(0)
expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(2)
expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(1)
})
it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => {
@@ -132,3 +137,57 @@ it('supplies localized disclosure copy without putting diagnostics in the ordina
dialogs!.cancel()
await pending
})
it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => {
const f = setup()
const abort = new AbortController()
const first = f.show(abort.signal)
const window = fixture.windows.at(-1)!
const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }
const next = f.show()
expect((await first).response).toBe(1)
expect(fixture.windows).toHaveLength(2)
expect(window.loadURL).toHaveBeenCalledOnce()
expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]])
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
abort.abort()
expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({
sender: window.webContents, senderFrame: window.webContents.mainFrame,
}, old.revision, 0)).toThrow(/stale/)
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
expect((await next).response).toBe(0)
})
it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => {
vi.useFakeTimers()
const f = setup()
const first = f.show()
const window = fixture.windows.at(-1)!
dialogs!.cancel()
await first
expect(window.isDestroyed()).toBe(false)
expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
const next = f.show()
await vi.advanceTimersByTimeAsync(150)
expect(window.isDestroyed()).toBe(false)
dialogs!.cancel()
await next
await vi.advanceTimersByTimeAsync(150)
expect(window.isDestroyed()).toBe(true)
expect(f.parent.listenerCount('focus')).toBe(0)
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => {
const f = setup()
const pending = f.show()
const window = fixture.windows.at(-1)!
const event = { preventDefault: vi.fn() }
f.parent.webContents.emit('before-input-event', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
f.parent.emit('focus')
expect(window.focus).toHaveBeenCalledTimes(2)
dialogs!.dispose()
await pending
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
@@ -25,12 +25,13 @@ function page(name: string) {
it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyboard-accessible: %s', async (language) => {
const p = page('update-dialog')
const locale = resolveDesktopLocale(language)
const state: UpdateDialogView = { locale: locale.id, title: locale.messages.updateFailedTitle,
const state: UpdateDialogView = { revision: 1, locale: locale.id, title: locale.messages.updateFailedTitle,
message: locale.messages.updateStopFailed, detail: '', buttons: [locale.messages.updateAcknowledge], cancelId: 0,
closeLabel: locale.messages.updateClose, technicalDetailsLabel: locale.messages.updateTechnicalDetails,
technicalDetails: '<img src=x onerror="window.compromised=true">\nexit 0; shutdown acknowledged false' }
const respond = vi.fn(async () => {})
const api: UpdateDialogApi = { status: async () => state, respond }
let publish!: (view: UpdateDialogView | null) => void
const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } }
Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api })
p.run()
await expect.poll(() => p.element('dialog').hidden).toBe(false)
@@ -54,7 +55,24 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb
expect(disclosure.open).toBe(false)
expect(respond).not.toHaveBeenCalled()
p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))
expect(respond).toHaveBeenCalledWith(0)
expect(respond).toHaveBeenCalledWith(1, 0)
const backdrop = p.document.body
const dialog = p.element('dialog')
dialog.scrollTop = 170
publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] })
expect(p.document.body).toBe(backdrop)
expect(p.element('dialog')).toBe(dialog)
expect(dialog.scrollTop).toBe(0)
expect(backdrop.classList.contains('visible')).toBe(true)
expect(p.element('actions').childElementCount).toBe(1)
expect(disclosure.open).toBe(false)
expect(p.document.activeElement).toBe(dialog)
dialog.scrollTop = 80
publish(state)
expect(dialog.scrollTop).toBe(80)
expect(p.element('title').textContent).toBe(locale.messages.updateChecking)
publish(null)
expect(backdrop.classList.contains('visible')).toBe(false)
})
it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => {
@@ -100,6 +118,11 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori
expect(disclosure.hidden).toBe(true)
expect(p.element('error').hidden).toBe(true)
expect(p.document.getElementById('quit')).toBeNull()
expect(p.document.body.classList.contains('visible')).toBe(true)
publish({ ...initial, policy: { blocking: false, checking: false } })
expect(p.document.body.classList.contains('visible')).toBe(false)
publish(initial)
expect(p.document.body.classList.contains('visible')).toBe(true)
p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide'))
expect(unsubscribe).toHaveBeenCalledOnce()
})
+25
View File
@@ -25,3 +25,28 @@ it('gives the Windows mandatory modal native move, resize, and maximize controls
expect(window.show).toHaveBeenCalledOnce()
expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce()
})
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const parent = Object.assign(new EventEmitter(), {
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
isDestroyed: () => false,
})
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
})
native.create.mockReturnValue(window)
try {
createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin')
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
const event = { preventDefault: vi.fn() }
parent.webContents.emit('before-input-event', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
window.emit('closed')
expect(parent.listenerCount('focus')).toBe(0)
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
} finally { platform.mockRestore() }
})
+53 -4
View File
@@ -17,7 +17,7 @@ import { join, sep } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -754,8 +754,57 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// The directory-browser aria golden is this spec's one owned artifact;
// the seed it reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'.gitkeep', 'directory-browser.expected.md', 'new-session.expected.md',
])
})
})
describe('web e2e: New Session after an outdated blank cache', () => {
it('opens a fresh conversation instead of reusing the recorded conversation', async () => {
const scaffold = await launchWebScaffold({})
let browser: Browser | undefined
try {
const now = Date.UTC(2026, 8, 16)
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'new-session-stale-blank',
undefined, { createdAt: now - 60_000 })
const stored = await scaffold.ctx.sessionPersistence.stat(id)
if (stored === undefined) throw new Error('seeded Session is missing')
// A durable log may advance after its last blank projection checkpoint.
// An unregistered Session writes only the checkpoint, leaving the persisted log intact.
const blank = scaffold.ctx.sessions.prepare(id, {
eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0),
})
await scaffold.ctx.sessionProjectionCache.write(blank)
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression')
await workspace.attachSession(id)
browser = await chromium.launch()
const page = await newEnglishPage(browser)
await page.clock.setFixedTime(now)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor()
const selected = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selected.innerText()).not.toBe('New Session')
const previous = (await selected.innerText()).split('\n')[0]!
const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
await expect.poll(() => selected.innerText()).toBe('New Session')
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => workspace.sessionIds.length).toBe(2)
expect(workspace.sessionIds).toContain(id)
await page.getByRole('treeitem').filter({ hasText: previous }).waitFor()
const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'new-session.expected.md'),
`Before New Session\n${before}\nAfter New Session\n${after}`, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
})
@@ -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/api/session-controller/README.md
README.md: fe6ed9200d520dee41945b1fc946a5a44472bdc8
README.zh.md: 0c2fc764eb20fcdf37c84f7d3b37ca8543e86b7d
README.md: 0ab2a452587da8e818e2744e59232fe67f15f79f
README.zh.md: 6819f07ae0a495896937a7097c5be2dcb0fbefc9
@@ -31,6 +31,8 @@ The Client journal validates exact V3 event envelopes before publishing follow s
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
@@ -31,6 +31,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证精
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含本地引用来源的正计数;Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
@@ -19,7 +19,7 @@ export interface SessionListEntry {
title?: string
updatedAt: number
running: boolean
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
/** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
@@ -233,6 +233,7 @@ export class SessionManager {
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* the reference allocator opens history after binding the scope).
* New instances reconcile retained metadata before returning.
* @param sessionId - the session to get.
* @returns the resident instance.
*/
@@ -256,6 +257,9 @@ export class SessionManager {
// durable history, even though child rows do not carry `blank`.
session.handleBlank(false)
session.handleRunning(child.activity === 'running')
} else {
// Retained metadata may have notified before this Session existed.
session.handleBlank(true)
}
}
}
@@ -286,9 +290,14 @@ export class SessionManager {
let store = this.projectionStores.get(sessionId)
if (store === undefined) {
store = new ProjectionValueStore()
// List rows project off store keys (title); any-key changes re-enter
// the manager's own batched rebuild channel.
store.subscribeAny(() => { this.notifier.markDirty() })
const projections = store
store.subscribeAny(() => {
// Newer history or control metadata corrects a resident Session's stale list hint.
if (projections.values().sessionListMetadata?.blank === false) {
this.sessions.get(sessionId)?.handleBlank(false)
}
this.notifier.markDirty()
})
this.projectionStores.set(sessionId, store)
}
return store
@@ -415,7 +424,7 @@ export class SessionManager {
this.summaries = mutations.reduce(applyMutation, baseline)
this.listState = 'idle'
this.listPhase = 'ready'
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
// Sessions reconcile list blank hints with their current metadata projection.
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
@@ -815,8 +824,12 @@ export class SessionManager {
const projectionStore = this.projectionStores.get(summary.sessionId)
const title = projectionStore?.get('title')
const projectionValues = projectionStore?.values()
const metadata = projectionValues?.sessionListMetadata
return {
...summary,
// Cached list hints can precede a history opening or control update.
blank: summary.blank && metadata?.blank !== false,
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
...(typeof title === 'string' && title !== '' ? { title } : {}),
...(projectionValues === undefined ? {} : { projectionValues }),
}
@@ -38,10 +38,10 @@ export interface SessionSummary {
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
readonly retainedBy: SessionRetainInfo['retainedBy']
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
* store carries every row, while the Workspace browser shows only the
* selected blank entry.
* Blankness from the Host summary reconciled with `sessionListMetadata`.
* New Session reuses a blank one targeting the same workspace. Filtering
* stays with the consumer: the store carries every row, while the Workspace
* browser shows only the selected blank entry.
*/
blank: boolean
updatedAt: number
@@ -552,13 +552,12 @@ export class Session implements SessionFace {
}
/**
* Blank-bit relay from the authoritative summary source (`session.list` and
* `api-session/added`). Monotone: once any signal (local first send,
* running flip, an earlier summary) cleared it, a stale true never
* re-blanks.
* @param blank - the summary's derived empty-log bit.
* Relay list blankness without overriding a started conversation established
* by the current projection, a local prompt, or running state.
* @param blank - whether the list or projection reports an unstarted conversation.
*/
handleBlank(blank: boolean): void {
blank = blank && this.projections.values().sessionListMetadata?.blank !== false
if (blank === this.blankBit) return
if (blank && (this.promptAttempted || this.running)) return
this.blankBit = blank
@@ -58,6 +58,28 @@ describe('SessionManager instances', () => {
expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
})
it('initializes blankness from metadata published before the Session and list exist', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
const published = Promise.withResolvers<undefined>()
const stopObserving = manager.subscribe(() => { published.resolve(undefined) })
try {
manager.handleControlFrame({ type: 'projection', sessionId: S1,
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
manager.handleControlFrame({ type: 'projection', sessionId: S2,
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
// The initial notification must finish while neither Session is resident.
await published.promise
expect(manager.getListSnapshot().items).toEqual([])
expect(manager.get(S1).getSnapshot()).toMatchObject({ blank: false, running: false })
expect(manager.get(S2).getSnapshot()).toMatchObject({ blank: true, running: false })
expect(manager.get('fk-unobserved' as SessionId).getSnapshot().blank).toBe(true)
} finally {
stopObserving()
await manager.dispose()
}
})
})
describe('SessionManager query lifetime', () => {
@@ -125,6 +147,28 @@ describe('SessionManager query lifetime', () => {
})
describe('list lifecycle', () => {
it('uses metadata received before the list without reblanking started rows', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
try {
manager.handleControlFrame({ type: 'projection', sessionId: S1,
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
manager.handleControlFrame({ type: 'projection', sessionId: S2,
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
remote.session.list.mockResolvedValue(ok({ items: [
summary(S1, { blank: true }), summary(S2),
] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot().items.map(item => item.blank)).toEqual([false, false])
expect(manager.get(S1).getSnapshot().blank).toBe(false)
expect(manager.get(S2).getSnapshot().blank).toBe(false)
manager.handleControlFrame({ type: 'projection', sessionId: S1,
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 7 })
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
} finally {
await manager.dispose()
}
})
it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValueOnce(ok({ items: [
@@ -146,6 +190,39 @@ describe('list lifecycle', () => {
}
})
it('keeps an opened conversation out of blank-session reuse after a stale list refresh', async ({ mock, remote }) => {
const stale = {
...summary(S1, { blank: true, cwd: '/workspace' }),
projections: { asOfSeq: 2, values: {
sessionListMetadata: { blank: true, lastPromptAt: null },
} },
}
remote.session.list.mockResolvedValue(ok({ items: [stale] as never[] }))
const manager = makeManager(mock, remote)
try {
await manager.refreshList()
const session = manager.get(S1)
expect(session.getSnapshot().blank).toBe(true)
// History opening can be newer than the metadata-only list cache.
session.projections.seed({ asOfSeq: SessionSeq(61), values: {
sessionListMetadata: { blank: false, lastPromptAt: 1200 },
} })
await vi.waitFor(() => {
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
expect(session.getSnapshot().blank).toBe(false)
})
await manager.refreshList()
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
expect(session.getSnapshot().blank).toBe(false)
manager.handleSessionAdded(stale)
expect(session.getSnapshot().blank).toBe(false)
} finally {
await manager.dispose()
}
})
it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
try {
@@ -58,6 +58,15 @@ describe('Session open', () => {
expect(session.getSnapshot()).toMatchObject({ blank: false, running: true })
}, COLD_BOOT_TIMEOUT_MS)
it('rejects a blank list hint when current metadata records a started conversation', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.projections.apply('sessionListMetadata', { blank: false, lastPromptAt: 1200 }, SessionSeq(8))
session.handleBlank(true)
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: false, running: false })
})
it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
const page = plainTurn(SessionSeq(10), 3, '问', '答')
@@ -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-models/README.md
README.md: 6feb95d987f11bcf81b9bfa2c67068567244dcec
README.zh.md: 9316d698bdf11f630f5815b9fffd0bbdc4546031
README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842
README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27
+1 -1
View File
@@ -43,7 +43,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid
### Adding and deleting providers
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
### First-run dialogs
@@ -43,7 +43,7 @@ kind: "package-reference"
### 新增与删除提供方
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
### 首次运行弹窗
@@ -236,7 +236,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
className={styles['input']}
type="text"
value={baseURL}
placeholder={t('customBaseUrlPlaceholder')}
placeholder={t(protocol === 'anthropic-messages'
? 'customAnthropicBaseUrlPlaceholder'
: 'customBaseUrlPlaceholder')}
aria-label={t('baseUrl')}
aria-invalid={baseUrlInvalid}
disabled={profileDisabled}
@@ -93,6 +93,7 @@ export const en = {
customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.',
customNeedsModels: 'A custom provider needs at least one model.',
customBaseUrlPlaceholder: 'https://gateway.example/v1',
customAnthropicBaseUrlPlaceholder: 'https://gateway.example',
settingsPathUnresolvable: 'unresolvable settings path',
create: 'Create provider',
creating: 'Creating\u2026',
@@ -204,6 +205,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
customBaseUrlPlaceholder: 'https://gateway.example/v1',
customAnthropicBaseUrlPlaceholder: 'https://gateway.example',
settingsPathUnresolvable: '无法解析设置路径',
create: '创建提供方',
creating: '创建中\u2026',
@@ -1372,10 +1372,18 @@ describe('hand-declared providers', () => {
it('creates with the chosen protocol and no display name', async () => {
const { mutate, onClose } = mountCard()
const baseUrl = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
const protocol = screen.getByLabelText(en.customApi)
expect(baseUrl.placeholder).toBe('https://gateway.example/v1')
fireEvent.change(protocol, { target: { value: 'openai-responses' } })
expect(baseUrl.placeholder).toBe('https://gateway.example/v1')
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'anthropic-messages' } })
fireEvent.change(baseUrl, { target: { value: 'https://acme.test/anthropic' } })
fireEvent.change(protocol, { target: { value: 'anthropic-messages' } })
expect(baseUrl.placeholder).toBe('https://gateway.example')
expect(baseUrl.value).toBe('https://acme.test/anthropic')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
@@ -1387,7 +1395,7 @@ describe('hand-declared providers', () => {
// nothing ever sets. The with-key case is covered above.
expect(firstMutate(mutate).ops[0]?.value).toEqual({
api: 'anthropic-messages',
baseURL: 'https://acme.test/v1',
baseURL: 'https://acme.test/anthropic',
models: [{ id: 'm' }],
})
})
@@ -0,0 +1,9 @@
Before New Session
- tree "Sessions":
- treeitem "New session regression" [expanded]
- treeitem "Use the read tool twice now" [selected]
After New Session
- tree "Sessions":
- treeitem "New session regression" [expanded]
- treeitem "New Session" [selected]
- treeitem "Use the read tool twice now"