desktop-session-polish:会话配置、流式呈现与视觉契约

buffin-ai/buffin · origin/main...codex/desktop-session-polish · 2026-07-18 · 自包含,读完即弃

29 commits
151 文件
+6,757 / −937
29% 是测试代码

写法说明:本文按逐跳走读展开。代码节选均由分支工作区文件复读后裁剪;青色斜体注释为解读所加,灰色斜体为源码注释。每条旅程收尾给出排查路标。

1TL;DR

这是一个混合型桌面会话体验变更:Composer 不再把配置局限在组件内部,而是按草稿或真实会话隔离并可提交运行时变更;流式消息在权威事件文本与 Markdown 渲染之间新增播放缓冲;工具、代码块、表格和用户输入导航获得统一呈现组件。

支撑这些交互的 daemon/API 新增 providers.sessionOptions,一次返回 provider 自有的模型和配置字段。仓库同时把颜色、圆角、CSP 与框架 CSS 变量的例外写成可执行的校验规则。已有开放 PR:#152

2变更地图

apps/desktop
5,925 行 · 82%
scripts
1,391 行 · 19%
apps/daemon
295 行 · 4%
packages/api
13 行 · <1%
设计重心(要细读)可放心略过
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数据与状态先行

apps/desktop/src/renderer/features/session/models/session-configuration-store.ts真实代码(节选)
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 持久化权威结果。

apps/daemon/src/agent/types.ts真实代码(节选)
export type AgentSessionOptions = {
  models: AgentModelOption[]
  sessionConfig: AgentConfigField[]
}

export type AgentConfigField = {
  key: string
  label: string
  type: 'select' | 'string'
  runtime?: boolean // 表示可通过已有 reconfigure 路径更新
}

4旅程 A:从 provider 选择到会话配置

全景 · 7 个核心模块
Composer
use-composer-config
tRPC
providers.sessionOptions
AgentService / Registryprovider adapter
ACP/Codex/Claude

A.1选择 provider 后,补齐其动态 schema

基础 provider 列表可先用于渲染选择器;选中项随后查询 session options,将当前 provider 的 model 和运行时字段替换成 provider 返回值。因而 ACP 不必把短生命周期发现结果塞入全局 availability。

apps/desktop/src/renderer/features/session/api/use-composer-providers.ts真实代码(节选)
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 字段。

apps/daemon/src/agent/registry.ts真实代码(节选)
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 }
以前
Composer 私有 state 选择 provider/model/config。
创建时依赖静态 availability schema。
现在
配置以 draft:<task>session:<id> 存储。
当前 provider 拉取 models + sessionConfig;动态字段可在创建前校验。

A.2创建提升草稿;运行中回收服务端权威值

首次发送成功后,客户端将草稿 scope 改为真实 session scope。已有会话的运行时字段先显示预期值,再以 agent.reconfigure 的返回快照 resolve;失败时恢复前值。

apps/desktop/src/renderer/features/session/components/SessionView.tsx真实代码(节选)
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.tsresolveCreateSessionOptions 与 config 校验
运行时修改回弹或报错use-composer-config.ts 的 mutation 状态与 use-session-actions.ts

5旅程 B:流式文本、工具输出与会话导航

全景 · 渲染与交互
session event storeThread presentationplayback bufferIncremark / CodeBlockscroll + minimap

B.1权威文本与播放文本分离

session store 继续持有收到的完整事件文本。播放缓冲只控制本次 mounted surface 展示到哪里:每 72ms 依积压量释放 2–12 个字符;文本替换时直接 settle,完成后仍会 drain 余量。这样 Markdown renderer 不再被每个事件增量立即重建。

apps/desktop/src/renderer/features/session/hooks/use-playback-buffer.ts真实代码(节选)
const nextLength = nextRevealLength(displayedLengthRef.current, targetText.length)
const revealedChars = nextLength - previousLength
setFrame({
  text: targetText.slice(0, nextLength),
  reveals,
  revision,
}) // 呈现帧,不替代权威消息

其下游新增共享 CodeBlock、GFM MarkdownTableToolRowBase disclosure 及按 read/bash 语言识别的工具输出。宽会话面板还从用户 text part 投影 LineMinimap,点击 marker 定位原消息;离开底部时出现回到最新消息按钮。

以前
活动消息直接交给 Markdown 组件。
工具各自实现展开;没有用户输入索引。
现在
播放 buffer 生产可动画的 reveal chunk,再交给 Incremark。
代码、表格、工具行共用 primitive;用户输入可从 minimap 回跳。

B.2后台 session view 可卸载,订阅不随之消失

apps/desktop/src/renderer/features/session/session-registration.ts真实代码(节选)
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.tsxcode-block.tsxToolRowBase.tsx
切换 tab 后 session view 重新挂载ContentHost.tsx 的 inactive timer 与 session-registration.ts

6旅程 C:把视觉规则变为仓库契约

这条旅程并不改变任务或会话的领域数据。它让 renderer 的圆角由 tokens.css 定义的语义名控制,避免尺寸名与任意值继续散落;新扫描器覆盖 TS/TSX AST、CSS、HTML、inline style 与动态 class 拼接。

scripts/check-radius-tokens.mjs真实代码(节选)
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心智模型补丁

Composer 配置只属于当前挂载的输入框。配置属于一个明确的草稿或 session scope,并能跨 remount 留存。
草稿创建成功后由服务端快照提升到真实会话。
provider list 的模型与配置是同一份静态资料。当前 provider 可以另行发现 session options,尤其是 ACP 的动态 schema。
收到的 assistant 文本就是立即渲染的文本。事件文本是权威状态;播放 buffer 只决定每个 view 当前展示的字符范围。
非活动 tab 会一直保留 renderer 与高亮状态。session tab 两分钟不活动后可卸载 view,但不关闭 tab 或 session stream。
圆角是局部样式选择。renderer 圆角是受 source scope 与静态扫描约束的语义契约。

8词表、测试与覆盖声明

新词表
session optionsprovider 返回的 { models, sessionConfig } 快照。
runtime field可走已有 agent.reconfigure 路径修改的 provider 字段。
reveal chunk播放缓冲在一帧中放出的文本范围,用于流式进入动画。
inactive unmountcontent 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。新增 usePlaybackBufferuseAutoOpenDisclosureuseComposerConfig 没有各自直接导入的测试文件;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 或遗留清单。