desktop-session-polish:会话配置、流式呈现与视觉契约
buffin-ai/buffin · origin/main...codex/desktop-session-polish · 2026-07-18 · 自包含,读完即弃
写法说明:本文按逐跳走读展开。代码节选均由分支工作区文件复读后裁剪;青色斜体注释为解读所加,灰色斜体为源码注释。每条旅程收尾给出排查路标。
1TL;DR
这是一个混合型桌面会话体验变更:Composer 不再把配置局限在组件内部,而是按草稿或真实会话隔离并可提交运行时变更;流式消息在权威事件文本与 Markdown 渲染之间新增播放缓冲;工具、代码块、表格和用户输入导航获得统一呈现组件。
支撑这些交互的 daemon/API 新增 providers.sessionOptions,一次返回 provider 自有的模型和配置字段。仓库同时把颜色、圆角、CSP 与框架 CSS 变量的例外写成可执行的校验规则。已有开放 PR:#152。
2变更地图
| 设计重心(要细读) | 可放心略过 |
|---|---|
| session configuration、播放缓冲、Incremark/代码/工具组件、provider session options、120 秒后台视图回收、radius token scanner。 | 大批 shell、设置、项目与任务组件仅把既有 rounded-* 值换为语义 token;lockfile、fake service 补项和 locale 是配套变更。 |
测试变更为 2,233 行,其余为 5,461 行。新文件集中在 renderer primitive、session state/hook 与 radius policy,不是一次机械目录迁移。
3数据与状态先行
export interface SessionConfiguration {
providerId: string
model: string
sessionConfig: Record<string, unknown>
}
export interface SessionConfigurationEntry {
configuration: SessionConfiguration
pendingKey: string | null
error: string | null
}
export function draftConfigurationScope(taskId: string) {
return `draft:${taskId}`
}
export function sessionConfigurationScope(sessionId: string) {
return `session:${sessionId}`
}
Configuration scope 是草稿或持久化会话的隔离键。前端 entry 记录乐观写入中的字段与错误;daemon 仍以既有 session 配置 JSON 持久化权威结果。
export type AgentSessionOptions = {
models: AgentModelOption[]
sessionConfig: AgentConfigField[]
}
export type AgentConfigField = {
key: string
label: string
type: 'select' | 'string'
runtime?: boolean // 表示可通过已有 reconfigure 路径更新
}
4旅程 A:从 provider 选择到会话配置
use-composer-config→tRPC
providers.sessionOptions→AgentService / Registry→provider adapter
ACP/Codex/Claude
A.1选择 provider 后,补齐其动态 schema
基础 provider 列表可先用于渲染选择器;选中项随后查询 session options,将当前 provider 的 model 和运行时字段替换成 provider 返回值。因而 ACP 不必把短生命周期发现结果塞入全局 availability。
const providerId = storedProviderId || fallbackProviderId || ''
const options = useQuery(
trpc.providers.sessionOptions.queryOptions(providerId ? { providerId } : skipToken, {
staleTime: 60_000,
retry: false,
}),
)
return providers.map((provider) =>
provider.providerId === providerId
? { ...provider, models: options.data.models, sessionConfig: options.data.sessionConfig }
: provider,
)
API 的新路由进入 registry。ACP adapter 以一个短 initialize → session/new 握手读取 provider 端的 model 和 thought_level,并把后者投影为 Buffin 的 effort 字段;Codex/Claude 则返回自身静态 runtime 字段。
const discovered = provider.listSessionOptions
? await provider.listSessionOptions()
: {
models: provider.listModels ? await provider.listModels() : (module.models ?? []),
sessionConfig: module.sessionConfig ?? [],
}
return { models: mergedModels(row, discovered.models), sessionConfig: discovered.sessionConfig }
draft:<task> 或 session:<id> 存储。A.2创建提升草稿;运行中回收服务端权威值
首次发送成功后,客户端将草稿 scope 改为真实 session scope。已有会话的运行时字段先显示预期值,再以 agent.reconfigure 的返回快照 resolve;失败时恢复前值。
const configurationStore = useSessionConfigurationStore.getState()
configurationStore.replace(sessionConfigurationScope(created.id), {
providerId: created.providerId,
model: created.model ?? '',
sessionConfig: created.sessionConfig,
})
configurationStore.remove(configurationScope) // 草稿不再保留
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 切换 provider 后模型或字段没有更新 | use-composer-providers.ts 的 query 与合并条件 |
| 动态字段创建会话被拒绝 | agent/service.ts 的 resolveCreateSessionOptions 与 config 校验 |
| 运行时修改回弹或报错 | use-composer-config.ts 的 mutation 状态与 use-session-actions.ts |
5旅程 B:流式文本、工具输出与会话导航
B.1权威文本与播放文本分离
session store 继续持有收到的完整事件文本。播放缓冲只控制本次 mounted surface 展示到哪里:每 72ms 依积压量释放 2–12 个字符;文本替换时直接 settle,完成后仍会 drain 余量。这样 Markdown renderer 不再被每个事件增量立即重建。
const nextLength = nextRevealLength(displayedLengthRef.current, targetText.length)
const revealedChars = nextLength - previousLength
setFrame({
text: targetText.slice(0, nextLength),
reveals,
revision,
}) // 呈现帧,不替代权威消息
其下游新增共享 CodeBlock、GFM MarkdownTable、ToolRowBase disclosure 及按 read/bash 语言识别的工具输出。宽会话面板还从用户 text part 投影 LineMinimap,点击 marker 定位原消息;离开底部时出现回到最新消息按钮。
B.2后台 session view 可卸载,订阅不随之消失
export const sessionRegistration: ContentKind<'session'> = {
kind: 'session',
Component: SessionTabViewLazy,
subscription,
inactiveUnmountMs: 120_000, // 两分钟后释放渲染与高亮状态
isRevivable,
}
ContentHost 到期后卸载 React view,但 tab 仍然存在,session 的数据订阅仍由 workbench engine/store 持有;重新聚焦会取消释放并重新 mount。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 流式文本停止推进或一次性跳完 | use-playback-buffer.ts 的 target、frozen 和 timer 分支 |
| 代码、表格或工具输出样式异常 | incremark-adapter.tsx、code-block.tsx、ToolRowBase.tsx |
| 切换 tab 后 session view 重新挂载 | ContentHost.tsx 的 inactive timer 与 session-registration.ts |
6旅程 C:把视觉规则变为仓库契约
这条旅程并不改变任务或会话的领域数据。它让 renderer 的圆角由 tokens.css 定义的语义名控制,避免尺寸名与任意值继续散落;新扫描器覆盖 TS/TSX AST、CSS、HTML、inline style 与动态 class 拼接。
function findSourceRadiusViolations(content, filePath, policy) {
const sourceScan = createSourceScan(content, filePath)
return [
...findRadiusClassViolations(content, filePath, policy, sourceScan),
...findInlineStyleRadiusViolations(content, filePath, policy, sourceScan),
].sort((a, b) => a.line - b.line)
}
if (!runRadiusTokenCheck()) process.exit(1)
bun run check 新增此 gate。CSP 同时精确允许 'wasm-unsafe-eval' 使 Shiki 的 WebAssembly 编译可运行,并保持对 unsafe-eval / unsafe-inline 的拒绝;theme checker 只新增 Shiki 与 collapsible 运行时变量的有限白名单。
tokens.css 声明或选择已有语义 token;在 renderer 使用相应 rounded-<role>;若无法表达为 token,紧邻源码写入校验器要求的例外理由。排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
bun run check 报 radius 违规 | check-radius-tokens.mjs 输出的文件、行和 reason;策略源为 tokens.css |
| Shiki 或折叠面板的 CSS token 被拒绝 | check-theme-tokens.mjs 的 framework token 集合 |
| renderer 无法使用 WebAssembly 高亮 | index.html CSP 与 check-csp.mjs |
7心智模型补丁
8词表、测试与覆盖声明
| 新词表 | |
|---|---|
session options | provider 返回的 { models, sessionConfig } 快照。 |
runtime field | 可走已有 agent.reconfigure 路径修改的 provider 字段。 |
reveal chunk | 播放缓冲在一帧中放出的文本范围,用于流式进入动画。 |
inactive unmount | content registry 宣告的后台 view 回收时间。 |
semantic radius token | 以界面角色而非尺寸命名、由静态检查保护的圆角 token。 |
| 有兜底的事实 | 薄冰(事实陈述) |
|---|---|
| configuration store 的初始化、提交、回滚与草稿提升;Composer 交互;code block、table、tool disclosure、line minimap;ContentHost 的回收/重挂载;ACP session-options tRPC handshake;radius/CSP/theme scanner。 | 新增 usePlaybackBuffer、useAutoOpenDisclosure 与 useComposerConfig 没有各自直接导入的测试文件;ACP parser 的空选项、分组选项和无效 current-value 分支没有本次新增直接断言;配置文件接入总 check 没有独立测试。 |
inactiveUnmountMs 只回收 React view,并不结束会话订阅;wasm-unsafe-eval 是为 Shiki 加入的单一 CSP 例外,并非开放一般 unsafe-*;大量 UI 文件仅替换语义 radius token。覆盖声明:本报告基于 origin/main...HEAD 的全部 151 个变更文件。大型 diff 由 desktop、daemon/API、杂项三个子系统分别逐文件精读;报告中的所有代码节选均由主作者复读其所在文件后手工裁剪。未读取随附计划文档,因为仓库中未发现与本分支对应的方案、run log 或遗留清单。
【分支/PR 名】:【一句话定性】
【仓库】 · 【base...head】 · 【日期】 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1【章节名】
【正文。一段只讲一件事。】
以前 · 【一句话】
现在 · 【一句话】
2旅程 A:【用户操作】
【旅程开场:一两句说这条旅程从哪到哪、走通后掌握了什么。】
【文件名】→ 【无需展开的环节】→ 【环节】
【文件名】
A.1【这一跳讲什么】
【叙述:先把问题/场景摆清楚,再上代码。】
export function example(input: Input): Output {
// 源码原注释的保留或意译用灰色
return transform(input) // 解读所加的注释用青色,指出这行为什么关键
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 【可观察的症状】 | 【文件】:【看哪个函数/哪个状态】 |
| 【分组名】 | |
|---|---|
【术语】 | 【一句白话解释】 |