PR #110 · Session UI 翻新
eyrie · main...feat/session-ui-polish-and-provider-integration · 2026-07-07 · 自包含 · 读完即弃
本文按逐跳走读展开。代码片段取自分支真实文件经裁剪,蓝色斜体注释为解读所加,灰色斜体为源码原注释。每条旅程结尾附排查路标。
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变更地图
| 设计重心 | 可略过 |
|---|---|
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:
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 → 选择后发消息 → 会话创建带上所选配置。
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:
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 追踪选中状态:
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 行,危险值标红:
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:
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:
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 }],
})
'builtin-claude-code'A.4已有会话恢复配置
ReadyPhase 查 sessions.get 取持久化的 provider/model/sessionConfig 构造 defaultConfig,透传给 FloatingComposer 的 useEffect 同步到各 state:
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 重命名
WorkbenchNavRow.tsx→ useInlineEditField→ sessions.update
WorkbenchNavRow 分流为 SessionNavRow(kind === 'session')和 PlainNavRow。Session 行 hover 显铅笔图标(absolute 定位不影响布局),点击切换 inline input:
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 拒绝消息
ApprovalTool.tsx→ useSessionActions
use-session-actions.ts→ approvals.respond
Deny 按钮不再直接提交——展开 inline input 收集原因,Enter 确认:
if (opt.effect === 'deny') {
setShowDenyInput(true) // 展开输入框
} else {
onResolve?.(opt.optionId)
}
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:
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 订阅增量」。
dispatcher.ts→ loadSnapshotThenSubscribe
dispatcher.ts→ agent.snapshot query→ openAgentStream(cursor)
原先 startSubscription 的 agent.events case 内联了 60 行逻辑(cursor-less replay + resync recovery)。现在提取为独立函数 startAgentSubscription,并新增两阶段加载:
const resumeCursor = handoffCursor ?? cachedAgentResumeCursor(ctx, subscription)
if (resumeCursor !== undefined) {
openAgentStream(resumeCursor) // 热恢复:直接从 cursor 订阅增量
} else {
loadSnapshotThenSubscribe() // 冷启动:先查 snapshot 种缓存
}
loadSnapshotThenSubscribe 调 agent.snapshot.query 获取历史事件批量写入 RQ cache,然后从 snapshot cursor 开始订阅。Resync 触发时也走这条路径替代旧的 cursor-less 重放:
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)
})
}
排查路标 · 旅程 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 popover | string field 被 filter 跳过不渲染 | 当前无 provider 使用 |
| PRD 提 loading + retry user story | 未实现 loading indicator / error retry | providers.list 几乎总命中缓存 |
9心智模型补丁
10新词表
| Provider 发现 | |
|---|---|
AgentAvailabilityDto | 后端 provider 描述:providerId, name, available, models[], sessionConfig[] |
AgentConfigFieldDto | 动态配置字段 schema:key, type, label, options, default |
useProviders | providers.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 |
configRef | RefObject<ComposerConfig>,useLayoutEffect 同步,读取零 re-render |
11测试与风险地图
- 🟠FloatingComposer 多 useEffect 状态同步链(defaultConfig 恢复 + stale 清除)仅有渲染级测试,无 state 机细粒度覆盖
- 🟡ApprovalTool deny message 交互(展开/收起/Enter 提交)无单测
- 🟡TabStrip "+" → createActivity 链路无测试覆盖
- 🟡dispatcher snapshot 失败 fallback 到 cursor-less 的路径在 test 中未覆盖 error case
- ⚪视觉变更(shadows、动画、theme token 适配)仅可 E2E 验收
12覆盖声明
全部 26 个变更文件逐文件精读(全量工作区版本 + git diff),无略读。PRD + UI checklist 通读用于偏差分析。5 commits 逐个审查(含 packages/client dispatcher 重构 +91/-60),5 个并行 subagent 精读后主力二次验证。代码片段均亲手裁剪。