PR #124:把两条各写一遍的侧边栏收成一个壳,并让空着的「全局平面」真正跑起来

eyrie · origin/main...feat/ui-ux-polish(merge-base d6270d1f) · 2026-07-07 · 自包含,读完即弃

24 commits
68 文件
+2334 / −565
37% 是测试代码
类型:重构 + 功能 + 修复 混合

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

1TL;DR

这个 PR 干了三件相互咬合的事。一,把侧边栏的「外壳」抽成一个复用组件 ContentSidebar——折叠、边缘探出、拖宽、钉回这套交互从此只写一遍;它不认识 task 也不认识 session,工作台的任务树和全局平面的会话列表只是塞进这个画框的两张不同的画。二,把原来空壳的「全局平面」补成能用的功能:一条跨项目的会话导航,按项目分组,点任意一行就在共享的 global 工作台里打开那个会话。三,为支撑「按项目分组」,给会话的 API 契约加了一个 projectId 字段——这牵出 daemon 侧一处并发竞态的修复:关闭会话时不能再「关完回头查一遍」,否则会和并发的删除任务撞车。

动机来自设计文档里坐实的两个痛点:侧边栏的折叠逻辑在工作台平面写了一遍、还带着两个会互相打架的收起按钮(header 里一个、tab 条左侧一个,都改同一个状态);而「全局 / 驾驶舱」这个平面早就有了 SurfaceId 占位,却没有导航、没有路由,是个死链接。此外夹带了一轮对照 Notion 与原型 HTML 的视觉走查落地(抽屉动效、把手位置、终端留边、字号层级、选中态从竖条改药丸)。

2变更地图(称重)

近 2900 变更行里 37% 是测试,非测试代码约 1822 行,且高度集中在桌面渲染层。daemon 只动了约 250 行,但那一小块是本 PR 唯一的契约级 + 并发正确性改动,密度最高。

desktop/renderer/features
1477 行 · 51%
desktop/renderer/components
804 行 · 28%
daemon/src + tests
246 行 · 8%
desktop/renderer/lib
183 行 · 6%
desktop/renderer/routes
109 行 · 4%
其余(i18n / 配置 / 锁)
~80 行 · 3%
设计重心(要细读)可放心略过(机械/搬运/视觉)
components/ui/content-sidebar.tsx——折叠/探出/拖宽/焦点交接的状态机,全 PR 最密的一块 WorkbenchNavRowNavRow 纯改名(git 记成删+增,实为重命名带少量新增)
GlobalNav.tsx + use-all-sessions.ts——跨项目聚合、分组、翻页去重 store/nav-store.tslib/sidebar-store.ts 升级搬家(加了 width 与 clamp)
daemon closeSession / transitionSession——CAS 返回写入行的竞态修复 删除的 NavCollapseToggle.tsx(两个旧收起入口之一)
把手/抽屉几何:use-sidebar-chrome.ts + SplitContainer/PaneViewtopLeft 让位 i18n key 改名(workbench.nav.collapsesidebar.collapse 等)三语同步
SessionDtoprojectIdpackages/api 契约)+ daemon 侧 join 供数 BoardColumn/TaskCard 看板扁平化视觉(「restore flat kanban styling」,与主题无关的换皮)

称重结论:这不是一个「大改一小块设计」的 PR,而是设计密度均匀铺开——外壳状态机、全局平面数据链路、daemon 竞态三处都承载真实设计,测试占比高说明每处都有钉子。唯一明显可略过的是夹带的看板扁平化换皮和一批机械改名。

3架构一图流

结构上有两处「之前 vs 之后」值得先看。左边是侧边栏:以前折叠逻辑只长在工作台平面上、两个按钮抢一个状态、折叠即整块消失;现在是一个 shared 壳被两个平面平等地 wrap。右边是全局平面:以前 SurfaceId='global' 存在但无路由无导航;现在补齐了 /global 路由 + 跨项目会话导航 + 共享工作台引擎。

以前 · 侧边栏长在工作台里

工作台路由
死宽 w-60 / 折叠 return null
WorkbenchNav(自带 header)
NavCollapseToggle
两个按钮改同一 collapsed
PaneNavToggle
全局平面
无路由 · 死链接
(空)

现在 · 一个壳、两个平面

ContentSidebar(shared 壳)
受控 props + children
sidebar-store(单份持久化)
工作台平面
wrap · 塞 WorkbenchNav
同一个壳
/global 路由
wrap · 塞 GlobalNav
同一个壳

关键结构判断:「行」早就是内容无关的原语(吃一个 ContentTarget,靠内容注册表渲染标题和状态点),所以两个平面能复用同一种行;这个 PR 补的只是把「外壳」也抽成原语,再加一层各自的「分组 + 数据」。全局平面不是第三种东西,它就是全局作用域的工作台——同一个 WorkbenchView 引擎,只是 surfaceId'global'

4数据与状态先行

下潜前先认四个形状,后面旅程会反复用到。只看形状、不讲行为。

① 侧边栏持久状态:只有两个值

整个重构的地基是「持久态刻意只有 collapsed + width 两个值」——「探出(peeking)」是把手上的临时 hover 态,组件内 useState,不持久化。一份状态被所有平面共享,所以侧边栏在哪个平面都保持同样的宽窄形态。

lib/sidebar-store.ts持久化的两个字段 + 反投毒 merge
export interface SidebarStore {
  collapsed: boolean          // 折叠到边缘把手,跨所有平面
  width: number               // docked 宽度,重启后恢复,永在 min/max 内
  toggleCollapsed(): void
  setWidth(width: number): void
}

// 持久化 blob 是不可信的(旧版本、写一半、手改过):每个字段强制拗回契约,
// 而不是信任默认的浅合并——否则一个被污染的 width(字符串 / NaN / 10000)
// 会直接进 docked 面板的 inline style,变成抓不住的细条或飞出屏幕的面板
merge: (persisted, current): SidebarStore => {
  const incoming = persisted as Partial<SidebarStore> | undefined
  return {
    ...current,
    collapsed: typeof incoming?.collapsed === 'boolean' ? incoming.collapsed : current.collapsed,
    width: clampWidth(incoming?.width),   // 180–480 之间,越界或非法回落默认 248
  }
}

② 壳的受控接口:几何靠两个 offset 参数外部注入

壳自己不知道 tab 条多高。消费者通过 handleTopOffset(把手垂直位置)和 flyoutTopOffset(抽屉顶边)把几何注进来。这两个是二轮走查后从「一个参数」拆成「两个参数」的——把手要嵌进 tab 条行内,抽屉却要从 tab 条下方滑出,位置不再一致。

components/ui/content-sidebar.tsx受控接口(节选)+ 一条硬约束
export interface ContentSidebarProps {
  width: number
  collapsed: boolean
  heading: string
  handleTopOffset?: number    // 容器顶到折叠把手的距离(工作台居中放进 tab 条行)
  // 必须 ≥ handleTopOffset + SIDEBAR_HANDLE_SIZE:抽屉会盖在把手上,
  // 纵向重叠就会吞掉把手的「点击钉回」——这条注释是 PR review 的产物(见 §9)
  flyoutTopOffset?: number
  children: React.ReactNode   // 平面的导航体,docked 和 flyout 里渲染同一份
  onToggleCollapsed(): void
  onWidthChange(width: number): void
}

③ 会话 DTO 长出一个 projectId

本 PR 唯一的对外契约变更。会话在数据库里绑的是 task,没有 project 列,所以 project 要从「所属 task」那边解析出来。

packages/api/src/dto.tsSessionDto 的形状变化
export type SessionDto = {
  id: string
  taskId: string
  projectId: string   // 新增:所属项目,从所属 task 派生(不是 session 自己的列)
  providerId: string
  title: string | null
  // ...其余不变
}

④ CAS 转换的返回类型:布尔 → 行 或 null

这个类型改动是 §8 竞态修复的支点。以前「转换成功了吗」只回一个布尔;现在把写入的那一行本身原子地带回来,调用方就不必再回头查一次(那一次回查正是竞态窗口)。

apps/daemon/src/agent/repository.ts接口签名的变化
transitionSession(
  id: string,
  from: SessionStatus[],
  to: SessionStatus,
  patch?: UpdateSessionPatch,
  options?: { requireActive?: boolean },
): Promise<AgentSessionRow | null>   // 以前是 Promise<boolean>;现在回写入行或 null

5底座:侧边栏状态机

三条旅程里有两条穿过同一段代码:ContentSidebar 的三态渲染。先把这段底座走通,旅程里就只讲各自特有的部分。

壳有三种可见形态,由 collapsed(外部持久态)和 peeking(组件内本地态)两个布尔决定:docked(展开的常驻面板 + 右缘拖宽把手)、collapsed 且未探出(只剩一个 边缘方钮)、collapsed 且探出(抽屉滑出,内容与 docked 完全一致)。docked 和抽屉复用同一段 header + children 渲染,只有外层容器不同——这就是「一个组件两种模式、不写两遍」的落点。

components/ui/content-sidebar.tsxdocked 分支:一个 flex 子项
if (!collapsed) {
  return (
    <nav data-content-sidebar="docked"
      className="relative flex h-full min-h-0 shrink-0 flex-col border-r border-border-soft bg-bg"
      style={{ width }}>
      <SidebarHeader heading={heading} cornerLabel={collapseLabel} onCorner={onToggleCollapsed}>
        <ChevronsLeft />   // 收起角标 «,只有 docked 态显示
      </SidebarHeader>
      <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
      <ResizeEdge width={width} label={resizeLabel} onWidthChange={onWidthChange} />
    </nav>
  )
}

折叠态返回一个 fragment,里面三样东西并存:边缘热区(隐形,鼠标停留触发探出)、 把手(可见方钮,hover/focus/click)、抽屉本体(常驻 DOM,靠 translateX 滑进滑出)。抽屉必须常驻——若卸载即消失就做不了退出动画——所以关闭时用 inert 把它对键盘和读屏隔离,只有可见时才可达。

状态机里最容易被忽略的是焦点交接。折叠或钉回的那一刻,被点击的按钮会随它的容器一起卸载,焦点本会掉到 <body> 上,键盘用户就迷路了。所以壳显式地把焦点接住:折叠后落到取代面板的把手,钉回后落到 docked 的角标按钮。

components/ui/content-sidebar.tsxcollapse/pin 之间的焦点交接
const prevCollapsed = useRef(collapsed)
useEffect(() => {
  if (prevCollapsed.current === collapsed) return
  prevCollapsed.current = collapsed
  if (collapsed) {
    suppressFocusPeek.current = true   // 抑制标记:这次是程序移焦,别让把手的 onFocus 立刻又探出
    handleRef.current?.focus()          // 折叠 → 焦点落到边缘把手
  } else {
    collapseRef.current?.focus()        // 钉回 → 焦点落到 docked 角标
  }
}, [collapsed])
新增一个「页内侧边栏」的标准接线:① 平面外层套 position: relative(把手和抽屉靠它做绝对定位锚点);② 用 useSidebarChrome() 取共享的持久态 + 标签 + 两个 offset;③ 把它 spread 进 ContentSidebar,只额外补自己的 heading 和导航体 children。三步之外不碰折叠/宽度/焦点,chrome 就不会在平面之间漂移。

6旅程 A:折叠之后,怎么让侧边栏再回来

这是全 PR 最精巧的一块交互。折叠后侧边栏收成一个小方钮,用户要么点它钉回、要么让它临时探出看一眼。走通这条旅程,你会明白「把手为什么在 tab 条里」「为什么鼠标蹭到左边缘会弹出抽屉」「为什么这些都不会误触终端」。

全景 · 涉及 4 个文件
把手几何
use-sidebar-chrome.ts
tab 条让位
SplitContainer + PaneView
边缘热区 + 抽屉
content-sidebar.tsx

A.1把手嵌进 tab 条,让最左上的 pane 给它让位

二轮走查的导火索:折叠态下把手浮在 pane 内容上,终端的第一行直接被压住。修法是让把手不再「浮」,而是纵向居中嵌进 tab 条那一行(Notion 把汉堡键放顶栏同理)。位置由 chrome hook 从 tab 条高度算出来。

features/workbench/components/use-sidebar-chrome.ts两个 offset 从 tab 条高度派生
// 把手垂直居中于 tab 条那一行,读起来像 chrome 的一部分而非浮层
const SIDEBAR_HANDLE_TOP = (TAB_STRIP_HEIGHT - SIDEBAR_HANDLE_SIZE) / 2   // (38 − 28) / 2 = 5px
// 抽屉在 tab 条下方 + 小间隙,展开时不盖住 tab
const SIDEBAR_FLYOUT_TOP = TAB_STRIP_HEIGHT + 8                            // 46px

但把手落在 tab 条行的左端,会压住第一个 tab。所以最左上那个 pane 在折叠时要把自己的 tab 条向右缩进,给把手让出位置。「哪个 pane 是最左上」这个信息,靠一个 topLeft 布尔沿分裂树递归下传——沿任一轴,只有第一个孩子还贴着左上角。

features/workbench/components/SplitContainer.tsxtopLeft 沿每个 group 的首孩子下传
{group.children.map((child, index) => (
  <GroupChild
    key={childKey(child)}
    // ...
    topLeft={topLeft && index === 0}   // 只有首孩子继续「贴左上角」,其余一律 false
  />
))}

拿到 topLeft 的那个 pane,只在折叠时给 tab 条加一段左内边距 SIDEBAR_HANDLE_CLEARANCE(= 8 内缩 + 28 把手 + 6 间隙 = 42px)。展开时把手不在,内边距也就不加。

features/workbench/components/PaneView.tsx折叠 + 最左上 = 让位
const sidebarCollapsed = useSidebarStore((state) => state.collapsed)
// ...
<div className="flex shrink-0 items-stretch bg-bg-2"
  style={{
    paddingLeft: topLeft && sidebarCollapsed ? SIDEBAR_HANDLE_CLEARANCE : undefined,
  }}>
  <TabStrip surfaceId={surfaceId} pane={pane} targetOf={(id) => tabOf(id)?.target} />
</div>

A.2鼠标蹭到左边缘、停一下,抽屉也探出

owner 的补充点子:怕用户看不懂 是什么。所以折叠态下,平面左缘留一条 6px 宽的隐形热区,指针在上面停留 250ms 也能滑出抽屉。难点全在「怎么不误触」——用户划选、拖 tab、只是路过时都不该弹。三道护栏:热区极窄、要停留、且鼠标没按着键

components/ui/content-sidebar.tsx边缘热区的三道误触护栏
<div aria-hidden="true" data-sidebar-edge-zone
  className="absolute inset-y-0 left-0 z-20 w-1.5"   // w-1.5 = 6px 极窄热区(护栏一)
  onMouseEnter={(event) => {
    if (event.buttons !== 0) return                  // 按着鼠标键(拖 tab / 划选)直接不理(护栏二)
    cancelClose(); cancelEdgeDwell()
    // 停留才是护栏:路过的指针会立刻 leave 并取消掉这个计时器
    edgeDwellTimer.current = window.setTimeout(() => {
      edgeDwellTimer.current = null
      setPeeking(true)
    }, EDGE_PEEK_DWELL_MS)                            // 停满 250ms 才探出(护栏三)
  }}
  onMouseLeave={() => { cancelEdgeDwell(); scheduleClose() }}
/>

一个容易看漏的坐标细节:这条热区在平面容器的左缘,不是窗口左缘——它坐落在 60px 全局导航 rail 的右侧。所以自动化测试里从 x=2 去 hover 触发不了(那还在 rail 上),得从 x≈63 才进得了热区。这条热区是纯鼠标设计(aria-hidden):键盘和读屏用户走把手那条路,不需要这个。

A.3抽屉滑动,以及 Escape / 失焦时把焦点还回去

抽屉贴着左缘、只圆右侧两角,像从窗口边框里拉出来的屉子,不是悬浮卡片。它常驻 DOM,靠 translate-x-fulltranslate-x-0 的 200ms 过渡滑动;关闭态用 inert 封住焦点。

components/ui/content-sidebar.tsx抽屉:常驻 + inert + 滑动
<nav data-content-sidebar="flyout" data-peek={peeking}
  inert={!peeking}   // 关闭态把内容对焦点/读屏隔离,只有可见时可达
  className={cn(
    'pointer-events-auto absolute left-0 flex flex-col ... rounded-r-lg border border-l-0 ... shadow-xl',
    'transition-transform duration-200 ease-drawer motion-reduce:transition-none',
    peeking ? 'translate-x-0' : '-translate-x-full',   // 拉入 / 拉出双向动效
  )}
  style={{ top: flyoutTopOffset, width, maxHeight: `calc(100% - ${flyoutTopOffset + FLYOUT_BOTTOM_GAP}px)` }}>

探出是临时态,任何「用户不再看它」的信号都要收起。这里的坑是 Shift+Tab 从把手往回退:它不触发任何 mouse 事件、也不启动任何计时器,所以没有下面这段的话,抽屉会永远飘在平面上关不掉。壳在把手的 onBlur 里补了这个缺口——焦点离开把手、又没进抽屉,就关。

components/ui/content-sidebar.tsx把手失焦兜住键盘退出
onBlur={(event) => {
  // 焦点离开把手却没进抽屉,就关掉「因聚焦而探出」的那次;
  // Shift+Tab 离开不发 mouse 事件、不起计时器,没有这段抽屉会无限期悬浮
  const next = event.relatedTarget as Node | null
  if (next && flyoutRef.current?.contains(next)) return
  setPeeking(false)
}}

抽屉内部的 onKeyDown 处理 Escape:它先关掉探出,再把焦点交还把手(否则键盘用户被困在一个 inert 子树里),并置上 suppressFocusPeek 标记,免得那次程序移焦又把刚关掉的抽屉重新探出。

排查路标 · 旅程 A
症状从哪下手
折叠后 把手压住终端/第一个 tabPaneView.tsxtopLeft && sidebarCollapsedpaddingLeftSplitContainer.tsxtopLeft 递归传递
把手垂直没对齐 tab 条 / 抽屉盖住 tabuse-sidebar-chrome.tsSIDEBAR_HANDLE_TOP(5) 与 SIDEBAR_FLYOUT_TOP(46),源头是 TabStripTAB_STRIP_HEIGHT(38)
划选/拖 tab 时误弹抽屉content-sidebar.tsxdata-sidebar-edge-zoneevent.buttons !== 0 护栏 + EDGE_PEEK_DWELL_MS 停留
抽屉探出后关不掉 / 键盘迷路content-sidebar.tsx:把手与抽屉的 onBlur/onKeyDownsuppressFocusPeekscheduleClose
拖宽越界 / 重启后宽度异常sidebar-store.tsclampWidth + mergecontent-sidebar.tsxResizeEdge(window 上挂 pointermove、卸载 stopDrag

7旅程 B:在全局平面点一个会话行,它开进哪

全局平面从空壳变成能用的功能。这条旅程走一个用户点击:从「跨项目拉全部会话」到「按项目分组渲染」到「点一行开进共享工作台」。

全景 · 涉及 4 个文件
拉全部会话
use-all-sessions.ts
按项目分组 + 标题
GlobalNav.tsx
开进 global surface
NavRow → operations.ts

B.1翻页聚合、按项目分组、标题从聚合列表直接给

daemon 的列表接口有分页上限(每页最多 100)。全局平面要「所有项目所有会话」,所以有个 useAllSessions 翻完所有页。翻页有个竞态:daemon 按「最近更新」倒序排,若翻页途中某会话被 agent 动了一下,它会往前跳、在下一页重复出现。这里按 id 去重留第一个(重复 id 会撞 React key)。

features/workbench/api/use-all-sessions.ts翻页 + 去重
const all = await listAllPages(loadPage, {}, signal)
// daemon 按 updatedAt 倒序、翻页走固定 offset,agent 中途 bump 一个会话会让它换页重复
const seen = new Set<string>()
const items = all.items.filter((item) => {
  if (seen.has(item.id)) return false   // 留第一次出现,丢重复
  seen.add(item.id)
  return true
})
return { ...all, items }

GlobalNav 把这份列表按 projectId 分桶,桶按项目显示名排序(同名再按 id 兜底,避免同名项目随会话更新顺序抖动)。每行是复用的 NavRow。这里有一处 PR review 后修掉的细节:以前行标题走通用注册表的「按 task 查一页会话再找 id」,那条查询默认只取 50 个——某任务超过 50 个会话时,靠后的会话在全局导航里查不到自己,标题退化成 "Session"。现在直接把聚合列表已经攥在手里的标题传给行。

features/workbench/components/GlobalNav.tsx标题直传,绕过二手查询
<NavRow
  target={targetOf(session)}
  surfaceId="global"
  // 把这份列表已经有的标题交给行——descriptor 只看得到 task 的首页,
  // 靠后的会话本会丢掉真标题;无标题的会话留 undefined 走 descriptor 兜底
  title={session.title ?? undefined}
  subtitle={taskTitles.get(session.taskId)}
/>

B.2点一行:用 surface 覆盖开进 global,而非项目工作台

行本身是内容无关的原语,点击调 openInWorkbench(target, surface?)。默认它会从 target 的 projectId 推出「项目工作台」这个 surface;但全局平面的行要开进共享的 global surface,所以传了一个显式覆盖参数。这是「全局行和工作台行复用同一种行、只在开进哪个 surface 上分叉」的落点。

features/workbench/host/operations.tssurface 覆盖:全局行落到共享 surface
export function openInWorkbench(target: ContentTarget, surface?: SurfaceId): string | null {
  const key = keyOfTarget(target)
  if (key === null) return null
  const placement = surface ?? surfaceOfTarget(target)   // 有覆盖用覆盖,否则从 projectId 推项目 surface
  const store = useWorkbenchStore.getState()
  // 按注册表的 canonical key 找同 surface 上已开的 tab:开-或-聚焦,不开第二个
  const existing = findTabByKey(placement, key)
  store.switchSurface(placement)
  if (existing) {
    store.activateTab(placement, existing.paneId, existing.tabId)
    return existing.tabId
  }
  return store.openTab(placement, target)
}

行的高亮同理走覆盖:NavRowisContentOpen(target, 'global') 判断——只有当这个会话在 global surface 上有开着的 tab 才点亮;同一会话若只在它的项目 surface 上开着,全局行不亮。所以同一会话可以在两个平面各开各的、互不干扰。

排查路标 · 旅程 B
症状从哪下手
全局导航少显示了某个会话use-all-sessions.tslistAllPages 翻页 + id 去重(翻页竞态见 §12 薄冰)
某会话行标题显示成 "Session"GlobalNav.tsx 是否传了 title={session.title};无标题才应回落 descriptor
点全局行开错了 surface / 开出重复 taboperations.tsopenInWorkbenchplacement 覆盖 + findTabByKey 去重
全局行高亮态不对NavRow.tsxisContentOpen(target, surfaceId);surface 传的是 "global"
行副标题(所属 task)不出现GlobalNav.tsxuseBoards 聚合 + titlesByTask;board 在飞时行暂为单行

8旅程 C:关闭一个会话——为什么不能「关完回头查一遍」

这是 daemon 侧唯一的深水区,一个并发正确性修复。表面动机是 SessionDto 要带 projectId,于是「关闭会话」这个返回 DTO 的操作也得能拿到 project。顺着这条线拉出来的,是一个「关完回读」的竞态。

全景 · 涉及 3 个文件
守卫读(带 projectId)
agent/service.ts
CAS 写 + RETURNING
drizzle-repository.ts
从写入行作答
agent/service.ts

C.1projectId 在守卫读那一刻就一起取出来

会话表没有 project 列,要 join 所属 task 拿。关键设计:在关闭前那次「生命周期守卫读」里就把 projectId 一并取出来——task 的 project 一旦定了就不会变,所以这一次读到的 project 后面一直有效,不必再读第二次。

apps/daemon/src/agent/drizzle-repository.ts守卫读同时取回 project
async getSessionLifecycle(id: string): Promise<AgentSessionLifecycleRow | null> {
  const row = this.db
    .select({
      session: agentSessionsTable,
      taskArchivedAt: tasksTable.archivedAt,
      projectId: tasksTable.projectId,   // 和生命周期状态在同一次读里 join 出来
    })
    .from(agentSessionsTable)
    .innerJoin(tasksTable, eq(agentSessionsTable.taskId, tasksTable.id))
    .where(eq(agentSessionsTable.id, id))
    .get()
  // ...拼进 AgentSessionLifecycleRow
}

C.2CAS 用 RETURNING 带回写入行,答案不再靠回读

先看以前怎么错。关闭流程是:守卫读 → 打断当前 run → 释放 runner → CAS 把状态翻成 Closed → 再查一次这个会话组 DTO 返回。问题在最后一步:CAS 已经成功了、关闭已经生效,但如果此刻有一个并发的删除任务落地,外键级联会把这行会话直接删掉,那次回读就什么都查不到——一个已经成功的关闭,反而抛「找不到」失败。

以前 · 关完回读
守卫读(只取生命周期)
打断 run / 释放 runner
CAS 翻 Closed(只回 boolean)
getSession 回读组 DTO ← 并发 task delete 在此把行删了,回读扑空
现在 · 从写入行作答
守卫读(连 projectId 一起取)
打断 run / 释放 runner
CAS 翻 Closed + RETURNING 带回写入行
用写入行 + 守卫读的 projectId 直接作答,不再回读

CAS 层现在用 SQLite 的 RETURNING,把刚写进去的那一行和写操作原子地一起拿回来。

apps/daemon/src/agent/drizzle-repository.tsupdate ... RETURNING 原子拿回写入行
// RETURNING 把转换后的行与写原子地一起交回,答它的调用方(closeSession)
// 就不需要一次并发 task delete 能作废的回读
const row = this.db
  .update(agentSessionsTable)
  .set({ ...patch, status: to, updatedAt: Date.now() })
  .where(where)
  .returning()
  .get() as AgentSessionRow | undefined
return row ?? null

服务层于是这样作答:状态行来自 CAS 写入,project 来自关闭前的守卫读,两者拼起来就是完整 DTO——中间不留任何一次可能扑空的读。

apps/daemon/src/agent/service.tscloseSession:拼答案,不回读
const guarded = await this.getRunnableSession(sessionId)   // 守卫读,projectId 在这拿
await this.interruptCurrentRun(sessionId)
await this.runnerManager.dispose(sessionId)
const closed = await this.repo.transitionSession(sessionId, /* from */ ..., SessionStatus.Closed,
  { /* patch */ }, { requireActive: true })
if (!closed) throw new AppError({ code: EyrieErrorCode.resource.conflict })
// 用 CAS 写回的行 + 守卫读的 project(task 的 project 永不变)作答:
// 任何在此的回读都会和并发 task delete(FK 删掉会话行)竞态,把一个已生效的关闭搞成失败
return { ...closed, projectId: guarded.projectId }
为什么不是直觉做法:直觉是「关完再查一遍拿最新 DTO」。但「关闭」这个写已经成功,业务上就该报成功;再补一次读,等于把一个已完成事务的成败,交给一个可能被并发删除清空的查询去裁决。正确姿势是让写操作自己把结果带回来(RETURNING),把「不变量」(project)在更早的守卫读里锁住。
排查路标 · 旅程 C
症状从哪下手
关闭会话偶发抛「找不到」/「冲突」agent/service.tscloseSession:是否还在 getSession 回读;应从 closed + guarded.projectId 作答
SessionDto.projectId 为空 / 错项目services/sessions.tsinnerJoin(tasksTable)projectIdtoSessionDto(row, projectId) 第二参
归档会话被误翻成 Closeddrizzle-repository.tstransitionSessionrequireActiveisNull(archivedAt) 折进 CAS 谓词
CAS 返回类型相关的编译/逻辑错repository.tstransitionSession 现回 AgentSessionRow | null(不再 boolean),核对所有调用方

9计划 vs 实现的偏差

这是自己的 PR,照计划做成的部分不必赘述,偏差才是认知裂缝。下面每条来自设计文档的两轮走查 addendum 与 commit message 里的变卦线索。

主题计划原本实际做成 / 为什么变
探出形态 hover 时探出一张「四角圆角的悬浮卡片」,底部还带一条「钉住」bar 改成 Notion 式边缘抽屉:贴左缘、只圆右角、translateX 双向滑动、常驻 DOM + inert。原型的底部钉住 bar 直接删掉——鼠标一移开浮层就塌,那条 bar 根本够不着。owner 对照 Notion 走查后拍板。
把手位置 把手是块「瘦高条」(28×48),下移让开 tab 条即可 改成 28×28 方钮,嵌进 tab 条行内。导火索是终端第一行被把手压住。连带把「一个 offset」拆成 handleTopOffset / flyoutTopOffset 两个(把手在行内、抽屉在行下)。
边缘热区 计划里没有——只有把手和 hover 探出两条路 二轮走查中 owner 现加:怕用户看不懂 ,左缘 6px 热区停留 250ms 也探出。带三道误触护栏(窄/停留/未按键)。属于走查衍生的新交互,非原计划。
选中态 选中行用 bg-3 填充 + 一条 2px 左竖条 给层级 改成 药丸bg-bg-3 填充 + ring-border 1px 内描边 + font-medium,删掉左竖条(owner 嫌竖条生硬)。选中 tab 的顶部 2px accent 条保留不变。
SessionDto.projectId 设计文档 §9 只把「全局会话数据源走哪个 query」列为开放项,没预料到要改契约 实现中才发现:按项目分组必须知道每个会话的 project,而会话只绑 task。于是新增 projectId 契约字段 + daemon 全链路 join 供数——一个计划外的 API 变更。
close 回读竞态 计划里根本没有这一项 projectId 时顺藤摸出的:close 原本「关完 getSession 回读」,会和并发 task delete 竞态。改成 CAS RETURNING + 守卫读锁住 project。commit fix(daemon): return the closed row from the close CAS itself 是这次变卦的尸检报告。
字体 走查发现 Mac 上字体观感与原型差——根因是 --font-sans 首位是 -apple-system,Inter 永远轮不上。改为 bundle Inter Variable(fontsource,OFL)并置于首位,CJK 走各平台系统字体显式回退。

10心智模型补丁

侧边栏折叠逻辑只长在工作台平面,改它去动 WorkbenchNav 折叠/探出/拖宽/焦点全在 shared 的 ContentSidebar,两个平面共用;改交互去动 content-sidebar.tsx,改接线去动 use-sidebar-chrome.ts
收起侧边栏有两个按钮(header 的 NavCollapseToggle + tab 条的 PaneNavToggle)。 收起/展开的唯一入口是壳的角标 + 把手;那两个组件已删。
SessionDto 只有 taskId,要 project 得自己再查 task。 SessionDto 直接带 projectId(daemon join 供数);ContentTarget 也一直带 projectId 作为所有 kind 共享的 placement 字段。
关闭会话后回读一次拿最新 DTO 是安全的。 写操作成功后不再回读——用 CAS 的 RETURNING 带回写入行;回读会和并发 task delete 竞态,把已生效的关闭搞成失败。
「全局 / 驾驶舱」是个规划中的第三种平面。 它就是全局作用域的工作台——同一个 WorkbenchView 引擎、surfaceId='global',导航体 GlobalNavWorkbenchNav 并排在 features/workbench,没有新 feature。
带逗号的任意值(如 easing 曲线)可以直接写进 className。 带逗号的任意值会触发 Tailwind 的 class-detection 警告;命名收进 config(如 ease-drawer = transitionTimingFunction.drawer)。
同理 --font-sans 首位放 -apple-system 会让 bundle 的 Inter 永不生效——顺序即优先级。

11新词表

侧边栏壳
ContentSidebar页内侧边栏的外壳组件(shared 层),只管 chrome,不认识内容;受控。
peeking折叠态下「临时探出抽屉」的本地态,组件内 useState,不持久化。
SIDEBAR_HANDLE_CLEARANCE最左上 pane 折叠时给把手让出的左内边距(42px = 8 内缩 + 28 把手 + 6 间隙)。
topLeft沿分裂树递归下传的布尔,标记「哪个 leaf pane 坐在整个 surface 的左上角」,只它给把手让位。
ease-drawer抽屉滑动的缓动曲线,命名收进 tailwind config(带逗号任意值会触发检测警告)。
全局平面
global surface不属于任何项目的共享工作台 surface(surfaceId='global'),跨项目会话在这里组成一个工作集。
useAllSessions翻完 daemon 所有分页、跨项目拉全部会话的聚合 query,按 id 去重。
surface 覆盖openInWorkbench(target, surface?) 的第二参:全局行传 'global',让行开进共享 surface 而非项目 surface。
daemon
AgentSessionLifecycleRow会话行 + 父 task 归档状态 + projectId,在一次读里 join 齐,避免二次 task 查询被并发删除竞态。
CAS(compare-and-swap)「状态是 X 才翻成 Y」的原子写;这里 transitionSession 现用 RETURNING 把写入行一并带回。
requireActiveisNull(archivedAt) 折进 CAS 谓词的选项,让并发归档落在读-写窗口里时 CAS 直接落空,绝不覆盖已归档行。

12测试与风险地图

纯事实陈述:哪些行为被测试钉住了,哪些是已知的薄冰。

有兜底的

薄冰

🟡 offset 翻页竞态可能少一行useAllSessions 按固定 offset 翻页,daemon 按 updatedAt 倒序。翻页途中有会话被更新 → 它跳到前页重复、被 id 去重丢掉,而它挤走的那行没被补取——全局导航会静默少一个会话,直到下次整体重拉自愈。根治要把分页从 offset 改游标,属 API 级改动,本 PR 有意不修(窗口极窄 + 自愈)。
🟡 全局聚合缓存无 invalidationuseAllSessions 用独立 query key,与会话 feature 的 {taskId}-scoped invalidation 互不相通;新鲜度只靠路由重挂 + 窗口聚焦重拉。将来若有「全局平面常驻时可触发的会话 mutation」,必须显式 invalidate 这个 key,否则导航会陈旧。TSDoc 已记这条契约。
🟡 N+1 descriptor 查询仍在:全局行的状态点(running/attention 徽标)仍走 useSessionDescriptor,每个不同 task 补发一次 sessions.list({taskId})。标题已绕过(见 §7.1),但徽标那条查询保留——复用注册表行的可接受代价,非缺陷。
⚪ mono 字体未 bundle--font-mono 首位仍是 "JetBrains Mono",但没 bundle,未装的机器上不生效。sans(Inter)这次 bundle 了,mono 留作可选跟进。

13验收提示

14覆盖声明

本报告的代码片段全部来自主笔亲自 Read 过的分支文件、亲手裁剪,未引用任何二手转述。全量精读覆盖:桌面渲染层核心(content-sidebar.tsx 全文、sidebar-store.tsuse-sidebar-chrome.tsSplitContainer.tsxPaneView.tsxGlobalNav.tsxNavRow.tsxuse-all-sessions.tsuse-session-descriptor.tsoperations.tsTabStrip.tsxKindIcon.tsxWorkbenchNavTask.tsxWorkbenchNav.tsxBreadcrumb.tsxTerminalPanel.tsx、两个 global* 路由、tokens.css/tailwind.config.ts),daemon 侧全部改动(dto.ts ×2、sessions.tsservice.tsrepository.tsdrizzle-repository.ts 及竞态测试),以及设计文档 content-sidebar-design.md 全文(含两轮走查 addendum)与 24 条 commit message。

较少展开的:content-sidebar.test.tsx / sidebar-store.test.ts 只读了改动摘要与断言语义(未逐行);看板扁平化视觉(BoardColumn 等)只读 diff 未深究,因与主线无关;三语 i18n 资源只核对 key 增删。两个原计划分发的子读手(daemon 交叉核验、文档矿工)因 API 连接中断未产出,其覆盖范围已由主笔亲读吸收,无遗漏。