feat/session-component:真实 agent session 进入桌面任务页
eyrie · origin/main...HEAD · 2026-06-17 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这批未发布提交把桌面端的 session 从“只能在计划里存在”推进到“任务页能打开真实 agent timeline”:用户在任务详情里点 session,主面板切到 SessionPane,pane 激活 dispatcher 的 agent.events 订阅,renderer 从 TanStack Query cache 读取事件流并折叠成聊天线程。
为了让对话能重放出用户气泡,daemon 在 startTurn 里新增服务侧 message.user 事件,并把 DB 事件类型、codec、API 输入和 renderer reducer 一起接上。支线改动把任务 repo 选择从多选收敛成单选,目的很实际:创建 agent session 时只有一个明确 cwd。
2变更地图(称重)
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
apps/desktop | 新增 features/session,Board 主区接入,任务详情列出 sessions。 | 多数小组件是展示层:Bash/Edit/ToolRow/Reasoning 的折叠卡片。 |
apps/daemon | message.user 事件从 service 发出,经 codec 进入持久 log。 | 部分测试 fixture 补字段;不是新运行时路径。 |
packages/api/db/client | API 支持 agent.startTurn parts 和 agent.resolveApproval;DB 接受 user role。 | bun.lock 主要是 Incremark 与 React Query Devtools 依赖展开。 |
测试相关变更为 277 行,非测试为 2475 行;这不是纯 UI 搬运,真实关键在“事件契约 → 持久化 → cache timeline → reducer → UI”的管线贯通。
3架构一图流
以前 · Board 只渲染任务面板
现在 · 任务页可进入真实 session
4数据与状态先行
MessagePart 是 renderer 内部的聊天片段,不等于 daemon event。它把事件流中的 text、reasoning、tool、approval、ask-user 压成一个可渲染结构;后续 SessionThread 只关心这个结构,不直接读 tRPC event。
export interface MessagePart {
type: 'text' | 'reasoning' | 'tool-call' | 'ask-user' | 'approval'
text?: string | undefined
toolCallId?: string | undefined
result?: { output: unknown; status: 'completed' | 'failed' } | undefined
inputRequestId?: string | undefined
approvalId?: string | undefined
}
export interface Message {
id: string
role: 'user' | 'assistant'
parts: MessagePart[]
status: 'complete' | 'running'
}
daemon 侧新增的词是 message.user。它不是 provider 自己吐出的 assistant delta,而是 service 在用户 turn 开始时主动补进同一条事件日志,保证重放时用户气泡不会丢。
export const AgentEventType = {
RunStarted: 'run.started',
RunCompleted: 'run.completed',
MessageDelta: 'message.delta',
MessageCompleted: 'message.completed',
MessageUser: 'message.user',
// 这让用户消息进入统一事件词汇,而不是 renderer 本地乐观补洞。
}
export type AgentProviderEvent = AgentProviderEventBase & (
| { type: 'message.delta'; role: 'assistant' | 'reasoning'; text: string; itemId?: string }
| { type: 'message.completed'; itemId?: string; text?: string }
| { type: 'message.user'; text: string; parts?: AgentInputPart[] }
)
5旅程一:用户消息如何进入可重放 timeline
这条旅程从用户点击发送开始,到 UI 能在刷新后重建同一个用户气泡结束。关键不是“显示一行文本”,而是用户输入必须和 assistant 输出进入同一个持久事件序列。
agent/service.ts→编码落库
event-codec.ts→DB check 接受 user→cache timeline
dispatcher→reducer
session-store.ts
1.1service 先把用户消息写成事件,再启动 runner
AgentService.startTurn 先用 beginTurn 抢占 run,然后通过 SessionSink 发 message.user。这一步在 runner 启动前发生,所以即使 provider 后续才开始 streaming,timeline 也已经有了用户输入这一格。
const run = await this.repo.beginTurn(sessionId, {
id: createId(),
inputText: normalized.text,
startedAt: Date.now(),
})
try {
const liveHandle = this.runnerManager.get(sessionId)
const userEventSink =
liveHandle?.sink ??
new SessionSink(session.id, session.providerId, this.repo, this.broadcaster)
userEventSink.setCurrentRun(run.id)
await userEventSink.emit(buildUserMessageEvent(normalized))
// 用户消息归属刚创建的 run,和后续 assistant 事件使用同一条 session log。
const handle = await this.runnerManager.getOrCreate(...)
handle.sink.setCurrentRun(run.id)
await handle.runner.startTurn(normalized)
}
1.2codec 只持久化安全的用户 parts
文本 parts 会完整进 payload;混有 image path 的 parts 不进 payload,只留下归一化文本占位。这不是 UI 限制,而是持久日志的安全边界:host path 不能随事件流泄露给 replay 消费者。
function encodeMessageUser(
providerId: string,
event: Extract<AgentProviderEvent, { type: 'message.user' }>,
): EncodedEvent {
return eventResult(providerId, {
role: 'user',
payload: { text: event.text, parts: allTextParts(event.parts) ? event.parts : undefined },
// Image refs are withheld until the upload-ref contract is opaque...
rawJson: null,
})
}
DB schema 同步接受 message.user 和 role='user',否则 codec 发出的事件会在插入时被 check constraint 拦下。
typeValid: check(
'agent_events_type_valid',
sql`${table.type} IN (..., 'message.completed', 'message.user', 'tool.started', ...)`,
),
roleValid: check(
'agent_events_role_valid',
sql`${table.role} IS NULL OR ${table.role} IN ('assistant', 'reasoning', 'user')`,
),
1.3renderer 只处理它认识的事件,并增量折叠
useSessionRuntime 不重新 reduce 整条 timeline,而是记住 processed index,只处理新增 envelope。这个变化的直接后果是 streaming markdown 组件不会因为每个 delta 都重建旧 message 对象而重置动画。
while (processedRef.current < timeline.length) {
const envelope = timeline[processedRef.current]
if (!envelope) break
const event = envelope.event as { type: string }
if (isSessionEvent(event)) {
stateRef.current = sessionReducer(stateRef.current, event)
}
processedRef.current++
}
message.user 在 reducer 里创建完整的 user message;assistant delta 则只改最后一个 running assistant message。这里决定了排查“用户气泡不出现”和“assistant 流式文本错位”要看不同 handler。
function handleMessageUser(state: SessionState, event: Extract<SessionEvent, { type: 'message.user' }>): SessionState {
const parts: MessagePart[] = event.parts?.length
? event.parts.map((p) => ({ type: 'text' as const, text: p.type === 'text' ? p.text : `[${p.mimeType ?? p.type}]` }))
: [{ type: 'text', text: event.text }]
return { ...state, messages: [...state.messages, { id: crypto.randomUUID(), role: 'user', parts, status: 'complete' }] }
}
排查路标 · 用户消息入流
| 症状 | 从哪下手 |
|---|---|
| 刷新后用户气泡消失 | apps/daemon/src/agent/service.ts:确认 buildUserMessageEvent 是否发出;再看 event-codec.ts 是否落库。 |
| 带图片的用户输入显示成路径或空白 | apps/daemon/src/agent/event-codec.ts:看 allTextParts 与 payload 裁剪;session-store.ts 看 placeholder 展示。 |
| assistant streaming 一直重播 | apps/desktop/src/renderer/features/session/hooks/use-session-runtime.ts:看 processed index 是否被重置。 |
6旅程二:从任务页打开 session
这条旅程讲 UI 如何从任务详情进入 session。变化点不是新路由,而是在同一个 Board 页面里把左侧主区从 kanban board 临时切换成 session 工作区。
SavedTaskPanel.tsx→主区切换
routes/board.tsx→SessionPane
SessionPane.tsx→订阅选择
client-provider.tsx→消息树
SessionThread.tsx
2.1Board 主区在 board 与 session 间切换
以前左侧主区固定是 <Board />。现在 openSession 不为 null 时,左侧显示一个带返回按钮的 session 容器;右侧任务详情仍在,用来打开已有 session 或 draft session。
TaskDetailPanelBoardopenSessionSessionPane;返回按钮清空 openSession{openSession ? (
<div className="flex min-h-0 flex-1 flex-col">
<button type="button" onClick={() => setOpenSession(null)}>← Board</button>
{'sessionId' in openSession ? (
<SessionPane key={openSession.sessionId} sessionId={openSession.sessionId} />
) : (
<SessionPane key={`draft-${openSession.taskId}`} taskId={openSession.taskId} />
)}
</div>
) : (
<Board projectId={projectId} ... />
)}
2.2SessionPane 激活 dispatcher,而不是自己开 websocket
SessionPane 挂载时调用 useSessionActivation,它只告诉 client context 当前 active session 是谁。真正的订阅仍由 connection session 的 dispatcher 管理;renderer 组件只读 cache。
export function useSessionActivation(sessionId: string | null) {
const { setActiveAgentSession } = useClient()
useEffect(() => {
setActiveAgentSession(sessionId)
return () => setActiveAgentSession(null)
}, [sessionId, setActiveAgentSession])
}
function activeDispatcherSubscriptions(sessionId: string | null, projectId: string | null) {
const subscriptions = []
if (sessionId !== null) subscriptions.push({ topic: 'agent.events', sessionId })
if (projectId !== null) subscriptions.push({ topic: 'board.deltas', projectId })
return subscriptions
}
读路径走 useAgentEvents,它监听 TanStack Query cache 中某个 timeline query 的 hash。这个设计让 session UI 不直接拥有订阅生命周期,切页/StrictMode 的 socket 复用由 client provider 测试覆盖。
2.3SessionThread 按 part 类型选择展示组件
折叠后的 MessagePart 在这里被分发。用户消息只取 text 展示;assistant message 会先把连续 part 分组,再把 Bash/Edit 等工具走专门卡片,未知工具走通用 ToolGroup。
{groups.map((group, i) => {
const isLastText = isRunning && i === groups.length - 1 && group.type === 'text'
if (group.type === 'text')
return <MarkdownPart key={group.key} text={group.text} isStreaming={isLastText} />
if (group.type === 'reasoning') return <ReasoningBlock key={group.key} text={group.text} />
if (group.type === 'ask-user') return <AskUserTool ... />
if (group.type === 'approval') return <ApprovalTool ... />
if (group.type === 'tool-group') return <ToolGroupRouter key={group.key} tools={group.tools} />
})}
排查路标 · 打开 session
| 症状 | 从哪下手 |
|---|---|
| 点 session 后左侧仍是看板 | apps/desktop/src/renderer/features/tasks/components/SavedTaskPanel.tsx 与 routes/board.tsx:看 onOpenSession 是否传递。 |
| session 页面空白但 daemon 有事件 | use-session-activation.ts 与 client-provider.tsx:看 active subscription 是否包含 agent.events。 |
| 工具调用显示成普通列表或不展开 | SessionThread.tsx 的 ToolGroupRouter 和 ToolGroup.tsx。 |
7旅程三:从新任务到可运行 session
session 创建依赖 task 有 cwd。原先新建任务可以勾多个 repo;这批改动把 draft repo 变成单选,并在 daemon 里从 repo 的 gitCommonDir 推默认 working dir。这样用户新建任务后,后续 agent session 能拿到唯一工作目录。
task-draft-model.ts→默认 cwd
repos.ts→DraftSession create
SessionPane.tsx→startTurn
trpc/services.ts
export function createTaskDraft(projectRepos: readonly ProjectRepoDto[]): TaskDraft {
return {
title: '',
description: '',
priority: null,
repos: projectRepos.map((relation, index) => ({
projectRepoId: relation.id,
name: relation.repo.name,
selected: index === 0,
})),
labels: [],
}
}
export function selectedProjectRepoIds(draft: TaskDraft): string[] {
const selected = selectedRepo(draft)
return selected ? [selected.projectRepoId] : []
}
function defaultWorkingDirFromGitCommonDir(gitCommonDir: string): string | null {
if (gitCommonDir.endsWith('/.git') || gitCommonDir.endsWith('\\.git')) {
return gitCommonDir.slice(0, -5)
}
return null
}
const repo = this.getActiveRow(projectRepo.repoId)
const defaultWorkingDir = defaultWorkingDirFromGitCommonDir(repo.gitCommonDir)
const selectedWorkingDir = workingDir ?? defaultWorkingDir
DraftSession 用用户在 composer 里选的 model/reasoning/permission 创建 session,然后马上发第一轮 agent.startTurn。所以“新建 session”在 UI 上不是单独按钮,而是发送第一条消息时发生。
const created = await trpcClient.sessions.create.mutate({
taskId,
providerId: 'builtin-claude-code',
model: cfg?.model,
sessionConfig: cfg ? { effort: cfg.reasoning, permissionMode: cfg.permission } : undefined,
})
onCreated(created.id)
await trpcClient.agent.startTurn.mutate({
sessionId: created.id,
input: [{ type: 'text', text }],
})
排查路标 · 新任务到 session
| 症状 | 从哪下手 |
|---|---|
| 新建 session 报 no working dir | apps/daemon/src/services/repos.ts:看 task repo 是否有默认 cwd;再看 task-draft-model.ts 是否选中了 repo。 |
| composer 选项没有带进 session | SessionPane.tsx 的 configRef 与 sessions.create payload。 |
| 第一条消息没发出去 | SessionPane.tsx 的 DraftSession.handleSend,再看 packages/api/src/trpc.ts 的 agent.startTurn。 |
8计划 vs 实现偏差
| 计划 | 实际 | 为什么变 |
|---|---|---|
草案写的是 user.message 事件。 | 最终实现叫 message.user,并进入 DB type check 与 event vocabulary。 | 它和 message.delta/message.completed 同属 message 域,event-codec 也按 message item 落 role。 |
草案 useSessionRuntime 用 useMemo 全量 reduce timeline。 | 最终用 ref 记录 processed index,只处理新 envelope。 | Incremark streaming 需要旧 message 对象稳定;全量 replay 会让内容组件重置。 |
| 草案说没有 daemon user event 时可本地乐观插入用户气泡。 | 最终选择 daemon service 发 message.user。 | 刷新、重连、历史 replay 都需要同一条持久 timeline;本地乐观插入只能修当前 render。 |
| 草案只说 session 接入;没有强调任务 repo 选择。 | 实际额外把任务 repo 收敛为单选并推导默认 cwd。 | agent session 创建依赖一个明确 cwd;多 repo task 不能直接决定 runner 工作目录。 |
9心智模型补丁
agent.events timeline。runs.inputText。用户输入必须也进入 agent_events,否则 replay 后 UI 没有用户气泡。setActiveAgentSession 选择。agent.resolveApproval 或 inputRequests.respond。10新词表
| session/event | |
|---|---|
message.user | service 侧补进事件日志的用户消息,保证历史 replay 有用户气泡。 |
AgentInputPartDto | 用户 turn 的内容块,目前是 text 或 image;API 边界用数组接收。 |
SessionState | renderer 内部聊天状态;不是服务端 DTO,是 event reducer 的输出。 |
activeAgentSession | client context 里当前要订阅的 session id;dispatcher 根据它开关 agent.events。 |
| UI | |
DraftSession | 还没有 session id 的 composer;第一次发送时先创建 session 再 start turn。 |
IncremarkContent | 用于流式 markdown 展示的 React 组件;这里只对最后 running text part 开启 typewriter。 |
11测试与风险地图
| 有兜底的事实 | 覆盖位置 |
|---|---|
message.user codec 会持久化 text-only parts,并裁掉 image path。 | apps/daemon/tests/agent-event-codec.test.ts |
AgentService.startTurn 会在 runner 启动前发用户消息,并验证 parts/text normalization。 | apps/daemon/tests/agent-service-methods.test.ts |
| dispatcher 可以同时维护 board delta 与 agent events 订阅,并在 StrictMode stop/start 中复用 socket。 | apps/desktop/tests/trpc-client-session.test.ts |
| 真实 wsLink + daemon reset 后,timeline cache 能通过 resync 重新构建。 | apps/desktop/tests/trpc-self-heal.test.ts |
| 任务 draft repo 单选行为有模型测试。 | apps/desktop/src/renderer/features/tasks/task-draft-model.test.ts |
| 薄冰 | 事实范围 |
|---|---|
| 🟡 session UI 组件本身没有 renderer component test。 | 读到的测试主要覆盖模型、服务、dispatcher;未见 SessionPane/SessionThread 交互渲染测试。 |
🟡 draft session 创建把 provider 固定为 builtin-claude-code。 | SessionPane.tsx 当前硬编码 provider;报告只陈述现状,不判断是否应配置化。 |
| 🟠 approval resolve UI 已接 API,但注释仍说“no tRPC route”。 | use-session-actions.ts 的注释与当前 agent.resolveApproval 路由不一致;运行路径本身存在。 |
12覆盖声明
本报告按 origin/main...HEAD 全量称重,逐块读取了 session 相关 diff:daemon event/service/codec、API/router/services/schema、DB check、client session/logger、desktop session feature、Board 接入、任务 repo 支线、相关测试和两份随附 docs。完整 diff 中 bun.lock 未逐行讲解,只作为依赖展开处理;非 session 的 task repo 默认 cwd 支线已纳入旅程三。
未发布提交范围按 git log HEAD --not --remotes 是 9 个提交;PR diff 范围 origin/main..HEAD 显示 10 个提交,因为分支历史里还包含一个已存在于其它远端分支的 user-message commit。本报告解释代码差异时以最终 worktree 的 origin/main...HEAD 为准。