feat/session-component:真实 agent session 进入桌面任务页

eyrie · origin/main...HEAD · 2026-06-17 · 自包含,读完即弃

9 未发布 commits
55 文件
+2666 / −86
10.1% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

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
1880 行 · 68.3%
bun.lock
468 行 · 17.0%
apps/daemon
348 行 · 12.6%
packages/api
35 行 · 1.3%
packages/db
12 行 · 0.4%
packages/client
9 行 · 0.3%
子系统设计重心(要细读)可放心略过
apps/desktop新增 features/session,Board 主区接入,任务详情列出 sessions。多数小组件是展示层:Bash/Edit/ToolRow/Reasoning 的折叠卡片。
apps/daemonmessage.user 事件从 service 发出,经 codec 进入持久 log。部分测试 fixture 补字段;不是新运行时路径。
packages/api/db/clientAPI 支持 agent.startTurn parts 和 agent.resolveApproval;DB 接受 user role。bun.lock 主要是 Incremark 与 React Query Devtools 依赖展开。

测试相关变更为 277 行,非测试为 2475 行;这不是纯 UI 搬运,真实关键在“事件契约 → 持久化 → cache timeline → reducer → UI”的管线贯通。

3架构一图流

以前 · Board 只渲染任务面板

Task card
select task
SavedTaskPanel
Agent events
无 session UI 消费
Conversation

现在 · 任务页可进入真实 session

SavedTaskPanel
open existing / draft
SessionPane
SessionPane
setActiveAgentSession + cache read
Dispatcher

4数据与状态先行

MessagePart 是 renderer 内部的聊天片段,不等于 daemon event。它把事件流中的 text、reasoning、tool、approval、ask-user 压成一个可渲染结构;后续 SessionThread 只关心这个结构,不直接读 tRPC event。

apps/desktop/src/renderer/features/session/models/session-store.ts真实代码(节选)
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 开始时主动补进同一条事件日志,保证重放时用户气泡不会丢。

apps/daemon/src/agent/types.ts真实代码(节选)
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 输出进入同一个持久事件序列。

全景 · 涉及 5 个文件
写入 turn
agent/service.ts
编码落库
event-codec.ts
DB check 接受 usercache timeline
dispatcher
reducer
session-store.ts

1.1service 先把用户消息写成事件,再启动 runner

AgentService.startTurn 先用 beginTurn 抢占 run,然后通过 SessionSinkmessage.user。这一步在 runner 启动前发生,所以即使 provider 后续才开始 streaming,timeline 也已经有了用户输入这一格。

apps/daemon/src/agent/service.ts真实代码(节选)
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 消费者。

apps/daemon/src/agent/event-codec.ts真实代码(节选)
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.userrole='user',否则 codec 发出的事件会在插入时被 check constraint 拦下。

packages/db/src/index.ts真实代码(节选)
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 对象而重置动画。

apps/desktop/src/renderer/features/session/hooks/use-session-runtime.ts真实代码(节选)
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。

apps/desktop/src/renderer/features/session/models/session-store.ts真实代码(节选)
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 工作区。

全景 · 涉及 5 个文件
任务详情
SavedTaskPanel.tsx
主区切换
routes/board.tsx
SessionPane
SessionPane.tsx
订阅选择
client-provider.tsx
消息树
SessionThread.tsx

2.1Board 主区在 board 与 session 间切换

以前左侧主区固定是 <Board />。现在 openSession 不为 null 时,左侧显示一个带返回按钮的 session 容器;右侧任务详情仍在,用来打开已有 session 或 draft session。

以前
选择 task 只改变右侧 TaskDetailPanel
左侧始终是 kanban Board
现在
右侧 session 按钮写入 openSession
左侧切成 SessionPane;返回按钮清空 openSession
apps/desktop/src/renderer/routes/board.tsx真实代码(节选)
{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。

apps/desktop/src/renderer/features/session/hooks/use-session-activation.ts真实代码(节选)
export function useSessionActivation(sessionId: string | null) {
  const { setActiveAgentSession } = useClient()
  useEffect(() => {
    setActiveAgentSession(sessionId)
    return () => setActiveAgentSession(null)
  }, [sessionId, setActiveAgentSession])
}
apps/desktop/src/renderer/lib/client-provider.tsx真实代码(节选)
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

apps/desktop/src/renderer/features/session/components/SessionThread.tsx真实代码(节选)
{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.tsxroutes/board.tsx:看 onOpenSession 是否传递。
session 页面空白但 daemon 有事件use-session-activation.tsclient-provider.tsx:看 active subscription 是否包含 agent.events
工具调用显示成普通列表或不展开SessionThread.tsxToolGroupRouterToolGroup.tsx

7旅程三:从新任务到可运行 session

session 创建依赖 task 有 cwd。原先新建任务可以勾多个 repo;这批改动把 draft repo 变成单选,并在 daemon 里从 repo 的 gitCommonDir 推默认 working dir。这样用户新建任务后,后续 agent session 能拿到唯一工作目录。

全景 · 涉及 4 个文件
repo 单选
task-draft-model.ts
默认 cwd
repos.ts
DraftSession create
SessionPane.tsx
startTurn
trpc/services.ts
apps/desktop/src/renderer/features/tasks/task-draft-model.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] : []
}
apps/daemon/src/services/repos.ts真实代码(节选)
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 上不是单独按钮,而是发送第一条消息时发生。

apps/desktop/src/renderer/features/session/components/SessionPane.tsx真实代码(节选)
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 dirapps/daemon/src/services/repos.ts:看 task repo 是否有默认 cwd;再看 task-draft-model.ts 是否选中了 repo。
composer 选项没有带进 sessionSessionPane.tsxconfigRefsessions.create payload。
第一条消息没发出去SessionPane.tsxDraftSession.handleSend,再看 packages/api/src/trpc.tsagent.startTurn

8计划 vs 实现偏差

计划实际为什么变
草案写的是 user.message 事件。最终实现叫 message.user,并进入 DB type check 与 event vocabulary。它和 message.delta/message.completed 同属 message 域,event-codec 也按 message item 落 role。
草案 useSessionRuntimeuseMemo 全量 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心智模型补丁

可以把 session UI 当成静态 Conversation 或 mock transcript。session UI 是 daemon event log 的投影,展示状态来自 agent.events timeline。
排查从 event 是否产生、是否落库、是否进入 cache、是否被 reducer 接收四段切开。
用户输入只需要存在 runs.inputText用户输入必须也进入 agent_events,否则 replay 后 UI 没有用户气泡。
renderer 组件可以直接决定订阅哪个 session。组件只读 cache;活动订阅由 route/pane 生命周期通过 setActiveAgentSession 选择。
任务可以挂多个 repo,后续再决定用哪个。新建任务默认只选一个 repo,让 agent session 创建时 cwd 唯一。
approval/input request 只是 event 展示。它们是可交互 parts,UI 回写 agent.resolveApprovalinputRequests.respond

10新词表

session/event
message.userservice 侧补进事件日志的用户消息,保证历史 replay 有用户气泡。
AgentInputPartDto用户 turn 的内容块,目前是 text 或 image;API 边界用数组接收。
SessionStaterenderer 内部聊天状态;不是服务端 DTO,是 event reducer 的输出。
activeAgentSessionclient 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-codeSessionPane.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 为准。