PR #110 · Session UI 翻新

eyrie · main...feat/session-ui-polish-and-provider-integration · 2026-07-07 · 自包含 · 读完即弃

5 commits
26 files
+1333 / −278 lines
~25% test code

本文按逐跳走读展开。代码片段取自分支真实文件经裁剪,蓝色斜体注释为解读所加,灰色斜体为源码原注释。每条旅程结尾附排查路标。

1TL;DR

Session 创建从硬编码 builtin-claude-code + 静态 model 列表,改为从后端 providers.list 动态发现 provider / model / config 并渲染选择 pills。同时完成 Composer 视觉层级打磨(layered shadows、AnimatePresence 三态动画、compact permission 行)、工具输出暗色终端风底色、Session 行内重命名、TabStrip "+" 新建 tab、以及 Approval deny 附带文字原因。

2变更地图

session/components
780 行 · 50%
packages/client
321 行 · 20%
workbench/components
189 行 · 12%
docs/
148 行 · 9%
tests/
29 行 · 2%
session/api+hooks
22 行 · 1%
设计重心可略过
FloatingComposer.tsx — 动态 pill 渲染docs/ — 执行计划文档
dispatcher.ts — snapshot/subscription 拆分MarkdownPart.tsx — 无变更
SessionView.tsx — 生命周期messagePartLayout.ts — 仅导出常量
SessionContent.tsx — configRef 传输
WorkbenchNavRow.tsx — 重命名
ApprovalTool.tsx — deny message + icon
BashTool / EditTool / ToolGroup — theme token
PaneView + TabStrip

3数据形状先行

ComposerConfig 通过 configRef(React.RefObject)在 useLayoutEffect 中同步写入,父组件读取时零 re-render:

FloatingComposer.tsx类型定义
export type ComposerConfig = {
  providerId: string   // 用户选的 provider
  model: string        // 用户选的 model id
  sessionConfig: Record<string, unknown>  // 动态字段 bag
}

SessionConfig(SessionContent 传给 send 的参数)与之结构对齐。后端 AgentAvailabilityDto 是数据源头,每个 provider 携带 .models[].sessionConfig[](AgentConfigFieldDto)。

4旅程 A:动态选模型 → 创建会话

用户打开 Composer → 看到后端拉取的 provider/model pill → 选择后发消息 → 会话创建带上所选配置。

全景 · 5 文件
useProviders
use-providers.ts
SessionView
SessionView.tsx
SessionContent
SessionContent.tsx
FloatingComposer
FloatingComposer.tsx
CreatingPhase.send
SessionView.tsx

A.1Provider 发现

14 行 hook,包装 tRPC providers.list,staleTime 60s,窗口聚焦不 refetch:

session/api/use-providers.ts完整
export function useProviders() {
  const trpc = useTRPC()
  return useQuery(
    trpc.providers.list.queryOptions(undefined, {
      staleTime: 60_000,
      refetchOnWindowFocus: false,
    }),
  )
}

A.2Composer 动态渲染 Pills

FloatingComposer 接收 providers?: AgentAvailabilityDto[],用 useMemo + useState 追踪选中状态:

FloatingComposer.tsxprovider 选择逻辑
const availableProviders = useMemo(
  () => (providers ?? []).filter((p) => p.available),
  [providers],
)

const selectedProvider = useMemo(() => {
  if (selectedProviderId !== null) {
    return availableProviders.find((p) => p.providerId === selectedProviderId) ?? null
  }
  return availableProviders[0] ?? null  // 默认取第一个
}, [availableProviders, selectedProviderId])

Provider pill 仅在 availableProviders.length > 1 时显示。permissionMode 字段提出到底部 compact 行,危险值标红:

FloatingComposer.tsxdanger 检测
const PERMISSION_DANGER_VALUES = new Set(['bypassPermissions', 'dontAsk'])

const isDanger =
  field.key === 'permissionMode' &&
  typeof value === 'string' &&
  PERMISSION_DANGER_VALUES.has(value)

A.3创建会话提交

SessionContent.handleSend 在 creating 阶段从 configRef 读值传给 actions.send:

SessionContent.tsxhandleSend
const cfg = configRef.current
const config: SessionConfig | undefined =
  phase === 'creating' && cfg
    ? { providerId: cfg.providerId, model: cfg.model, sessionConfig: cfg.sessionConfig }
    : undefined    // ready 阶段不传 config
await actions.send(text, config)

CreatingPhase.send 把 config 直透到 sessions.create:

SessionView.tsxCreatingPhase
const created = await trpcClient.sessions.create.mutate({
  taskId,
  providerId: config?.providerId || 'builtin-claude-code', // 兜底
  model: config?.model || undefined,
  sessionConfig: config?.sessionConfig,
})
onCreated(created.id)  // 先 promote 再发 turn
await trpcClient.agent.startTurn.mutate({
  sessionId: created.id,
  input: [{ type: 'text', text }],
})
Before
写死 opus/sonnet/haiku 选项
硬编码 'builtin-claude-code'
sessions.create(providerId, model, reasoning, permission)
After
useProviders() 拉后端目录
按 schema 动态渲染 pills
configRef 直透 → sessions.create(providerId, model, sessionConfig)

A.4已有会话恢复配置

ReadyPhase 查 sessions.get 取持久化的 provider/model/sessionConfig 构造 defaultConfig,透传给 FloatingComposer 的 useEffect 同步到各 state:

SessionView.tsxReadyPhase
const defaultConfig: SessionConfig | undefined = session
  ? {
      providerId: session.providerId,
      model: session.model ?? '',
      sessionConfig: session.sessionConfig,
    }
  : undefined
排查路标 · 旅程 A
症状从哪下手
Provider pill 不显示use-providers.ts 检查 tRPC 返回;FloatingComposer showProviderPill
新建会话 provider 没传上去SessionContent handleSend 里 configRef.current;确认 phase === 'creating'
重开会话 model pill 错误SessionView ReadyPhase sessions.get 返回值;FloatingComposer defaultConfig 同步
Provider 消失后选择未清除FloatingComposer 第二个 useEffect:stale selection 清除

5旅程 B:Session 重命名

全景 · 1 文件
SessionNavRow
WorkbenchNavRow.tsx
useInlineEditField sessions.update

WorkbenchNavRow 分流为 SessionNavRow(kind === 'session')和 PlainNavRow。Session 行 hover 显铅笔图标(absolute 定位不影响布局),点击切换 inline input:

WorkbenchNavRow.tsx核心
function SessionNavRow({ target, open, descriptor }) {
  const [editing, setEditing] = useState(false)
  const field = useInlineEditField({
    identity: target.sessionId,
    serverValue: descriptor.title,
    onCommit: useCallback((next: string) => {
      rename.mutate({ sessionId: target.sessionId, title: next })
      setEditing(false)
    }, [target.sessionId, rename]),
  })
}

rename mutation 成功后 invalidate sessions.list,刷新所有 row title。

排查路标 · 旅程 B
症状从哪下手
铅笔不出现SessionNavRow group-hover:inline-flex
重命名后标题未更新rename onSuccess → invalidateQueries(sessions.list)

6旅程 C:Approval 拒绝消息

全景 · 3 文件
ApprovalTool
ApprovalTool.tsx
useSessionActions
use-session-actions.ts
approvals.respond

Deny 按钮不再直接提交——展开 inline input 收集原因,Enter 确认:

ApprovalTool.tsxdeny 分流
if (opt.effect === 'deny') {
  setShowDenyInput(true)  // 展开输入框
} else {
  onResolve?.(opt.optionId)
}
use-session-actions.ts新签名
const resolveApproval = useCallback(
  async (approvalId: string, optionId: string, message?: string) => {
    await trpcClient.approvals.respond.mutate({
      sessionId, approvalId,
      response: { optionId, ...(message ? { message } : {}) },
    })
  }, [trpcClient, sessionId],
)
排查路标 · 旅程 C
症状从哪下手
Deny 无输入框ApprovalTool showDenyInput + denyOption find
原因没传后端use-session-actions resolveApproval message 透传

7旅程 D:视觉层

BashTool / EditTool / ToolGroup 展开区统一 bg-bg-alt + rounded-b-lg,文字 text-ink-soft。后续 commit 把 bg-neutral-900 / text-neutral-* 全部替换为 theme token,让代码区适配 light/paper 主题。EditTool diff 用 bg-green-950/60(加行)和 bg-red-950/50(删行)。

Emoji 状态指示 (⚠ / ✓ / ✕) 全部换为 Lucide 图标 (AlertTriangle / Check / X),解决跨平台渲染不一致。

TabStrip 末尾新增 "+" 按钮,PaneView.handleAdd 为当前 tab 的 task 创建新 session:

PaneView.tsxhandleAdd
const handleAdd = useCallback(() => {
  const target = activeTab?.target
  if (!target) return
  void createActivity('session', target.taskId, target.projectId)
    .then((created) => { if (created) openInWorkbench(created) })
}, [pane.activeTabId, tabOf])

7b旅程 E:Session 冷启动 snapshot 拆分

这是 packages/client 层的核心重构——把 agent event 加载从「单一订阅流重放全部历史」拆为「snapshot query 先种缓存 → 再从 cursor 订阅增量」。

全景 · 2 文件
startAgentSubscription
dispatcher.ts
loadSnapshotThenSubscribe
dispatcher.ts
agent.snapshot query openAgentStream(cursor)

原先 startSubscription 的 agent.events case 内联了 60 行逻辑(cursor-less replay + resync recovery)。现在提取为独立函数 startAgentSubscription,并新增两阶段加载:

packages/client/src/dispatcher.ts冷/热分流
const resumeCursor = handoffCursor ?? cachedAgentResumeCursor(ctx, subscription)

if (resumeCursor !== undefined) {
  openAgentStream(resumeCursor)  // 热恢复:直接从 cursor 订阅增量
} else {
  loadSnapshotThenSubscribe()    // 冷启动:先查 snapshot 种缓存
}

loadSnapshotThenSubscribeagent.snapshot.query 获取历史事件批量写入 RQ cache,然后从 snapshot cursor 开始订阅。Resync 触发时也走这条路径替代旧的 cursor-less 重放:

packages/client/src/dispatcher.tssnapshot 加载
const loadSnapshotThenSubscribe = (): void => {
  snapshotAbort?.abort()
  snapshotAbort = new AbortController()
  const { signal } = snapshotAbort
  void client.agent.snapshot
    .query({ sessionId: subscription.sessionId })
    .then((snapshot) => {
      if (signal.aborted || !isCurrent()) return
      const key = agentTimelineQueryKey(ctx.connectionId, subscription.sessionId)
      if (snapshot.events.length > 0) {
        ctx.queryClient.setQueryData<AgentTimeline>(key, snapshot.events)
      }
      openAgentStream(String(snapshot.cursor))  // 从 snapshot 末尾续订
    })
    .catch((error) => {
      if (signal.aborted || !isCurrent()) return
      openAgentStream(undefined)  // snapshot 失败时 fallback cursor-less
      onError?.(error)
    })
}
Before
subscribe(cursor-less) → daemon 重放全部历史
逐条 onData append 到 timeline cache
resync → 清 cache + cursor-less 重新订阅
After
agent.snapshot.query → 批量种缓存
subscribe(snapshot.cursor) → 只收增量
resync → abort + 重走 snapshot 路径
排查路标 · 旅程 E
症状从哪下手
Session 打开后消息不加载dispatcher.ts loadSnapshotThenSubscribe:snapshot query 是否成功;queryClient.setQueryData 是否写入
消息重复出现resumeCursor 逻辑:是否正确走了热路径而非再次 snapshot
Resync 后消息丢失resync 触发 loadSnapshotThenSubscribe:snapshotAbort 是否正确 abort 旧请求

8计划 vs 实现偏差

计划实际原因
不可用 provider 显示为 disabled + tooltip直接 filter 掉,不出现在 UI避免用户困惑
手动刷新按钮调 providers.refresh未实现,依靠 60s staleTime 自动重拉MVP 降低复杂度
string 类型 field 渲染为 text input popoverstring field 被 filter 跳过不渲染当前无 provider 使用
PRD 提 loading + retry user story未实现 loading indicator / error retryproviders.list 几乎总命中缓存

9心智模型补丁

SessionConfig 是 { providerId?, model?, reasoning?, permission? } 命名字段 SessionConfig 是 { providerId, model, sessionConfig: Record } 通用 bag
reasoning、permission 现在是 sessionConfig 里的动态 key。
CreatingPhase 写死 provider 'builtin-claude-code' 从 configRef 读取用户选择,仅 null 时 fallback
工具输出展开区白底 统一 bg-bg-alt + text-ink-soft(theme token,适配 light/paper 主题)
第一版用了 bg-neutral-900 硬编码,后续 commit 修正为 theme token。
Emoji ⚠/✓/✕ 作状态指示 Lucide AlertTriangle/Check/X 图标,跨平台一致渲染
Agent events 冷启动 = cursor-less 订阅重放全部历史 冷启动 = snapshot query 批量种缓存 → 从 cursor 订阅增量
避免长 session 首次打开时逐条流式重建 timeline。
Composer send/spinner/stop 硬切 AnimatePresence mode="wait" spring 交叉淡入
Approval deny 直接提交 optionId 展开 inline input 收 message 后提交
Session 名不可编辑 导航行 hover 铅笔 → inline edit → sessions.update
TabStrip 无法新建 tab "+" 按钮为当前 task 创建新 session

10新词表

Provider 发现
AgentAvailabilityDto后端 provider 描述:providerId, name, available, models[], sessionConfig[]
AgentConfigFieldDto动态配置字段 schema:key, type, label, options, default
useProvidersproviders.list React Query hook,staleTime 60s
ConfigPill按 field schema 动态渲染的 pill(select → dropdown,boolean → toggle)
Dispatcher / 数据加载
startAgentSubscription提取的 agent.events 处理函数,含冷/热分流逻辑
loadSnapshotThenSubscribe冷启动:查 agent.snapshot → setQueryData → 从 cursor 订阅
agent.snapshot新 tRPC query,返回 { events, cursor }
bg-bg-alt主题感知暗色背景 token,替代 bg-neutral-900
UI 交互
useInlineEditField行内编辑 hook:value, onBlur commit, onKeyDown Enter/Escape
ComposerStatus状态机:idle → sending → running → idle | error
configRefRefObject<ComposerConfig>,useLayoutEffect 同步,读取零 re-render

11测试与风险地图

有兜底:SessionView draft promotion · 持久化 config 恢复 · FloatingComposer provider/model 渲染(新增 test) · WorkbenchNav 行渲染 · dispatcher snapshot 冷启动 + resync(runtime test 133 行新增)· SessionThread 分组逻辑
薄冰

12覆盖声明

全部 26 个变更文件逐文件精读(全量工作区版本 + git diff),无略读。PRD + UI checklist 通读用于偏差分析。5 commits 逐个审查(含 packages/client dispatcher 重构 +91/-60),5 个并行 subagent 精读后主力二次验证。代码片段均亲手裁剪。