feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件

四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有
邮件会话既不属于任何项目、彼此也不同组。

而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload
只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在
SSE 事件里,插件即使想用也拿不到。

- SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
  不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
- 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到
  兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活)
- 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`

## 2. 会话别名列不出工作区下的历史会话(无法选择)

workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须
JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace
填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p`
在只剩 Agent 回信可匹配时两边都对不上。

- `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace
  为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失
- `FindOrCreateDefaultSession` 同步改用会话的 workspace

## 3. 平台侧会话在补全里根本不存在

人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。

新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。
**上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway,
Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。

- 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有
  本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断
  这条到底是不是真的本侧会话
- **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是
  三态语义,指向不存在的会话直接 404
- **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像),
  而不是传空数组把镜像抹掉
- **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是
  派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名;
  它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
  出现几个点哪个都不确定的候选
- DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着

补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与
第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是
「缓存选型」而不是 brisk-harbor 这种随机短名。

## 4. 对话树看不见抄送与转发产生的分支

旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是
它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到
另一个;挂在原件上的转发分支同理。

改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向,
因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点
为什么并列,唯一的解释就是父邮件抄送给了两个人。

## 5. DSH 插件(Phase 7.7)

卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是
`followup()` 的参数形状:DSH 要完整的 UserMessage(content + source),
而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。

- `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的
  session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道
- `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的
  permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall,
  可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回
- 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 ——
  留在别名里会让它自己被解析器切开)
- 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处
  炸一个无关错误」

## 其他

- `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup`
  而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的
  foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰
  systemd(演练时误停过一次生产服务)
- 插件 dist/ 不进版本库,install.sh 负责构建
- `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
This commit is contained in:
2026-09-02 20:05:51 +08:00
parent 9d4718a412
commit ca64d12057
43 changed files with 3940 additions and 290 deletions

View File

@ -270,10 +270,9 @@ export async function markMailRead(id: string) {
*/
export async function getMailThread(
id: string,
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
opts: { offset?: number; limit?: number } = {}
) {
const q = new URLSearchParams();
if (opts.dir) q.set('dir', opts.dir);
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
const qs = q.toString();

View File

@ -1,10 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
import type { SessionCandidate } from '../types';
/**
* 三段式地址输入name -> @path -> .session
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
* 值本身始终是完整字符串 name@path.session。
*
* session 段的候选带标题与来源标记:一个工作区下可能有十几条会话,
* 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。
*/
export default function AddressInput({
value,
@ -22,6 +26,8 @@ export default function AddressInput({
}) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<string[]>([]);
// session 段的富候选,与 items 同序。其他段为空数组。
const [meta, setMeta] = useState<SessionCandidate[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
@ -50,14 +56,29 @@ export default function AddressInput({
if (cancelled) return;
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
const filtered = (res.suggestions || []).filter(s =>
s.toLowerCase().includes(frag.toLowerCase())
);
const lower = frag.toLowerCase();
const all = res.suggestions || [];
const cands = res.candidates || [];
// 过滤时保持 suggestions 与 candidates 同序candidates 是按下标对应的,
// 分别过滤两个数组会让标题错位到别的别名上。
const keep: number[] = [];
all.forEach((s, i) => {
const c = cands[i];
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
if (hay.includes(lower)) keep.push(i);
});
setKind(res.kind);
setItems(filtered);
setItems(keep.map(i => all[i]));
setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
setActive(0);
} catch {
if (!cancelled) setItems([]);
if (!cancelled) {
setItems([]);
setMeta([]);
}
}
};
const t = setTimeout(run, 120);
@ -107,7 +128,11 @@ export default function AddressInput({
};
const hint =
kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名new 为新建)';
kind === 'name'
? 'Agent 名'
: kind === 'path'
? '工作区路径'
: '会话别名new 为新建)';
return (
<div ref={boxRef} className="relative">
@ -126,28 +151,58 @@ export default function AddressInput({
/>
{open && items.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="absolute z-20 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
{hint}
</div>
{items.map((s, i) => (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
>
{s}
{kind === 'session' && s === 'new' && (
<span className="ml-2 text-[10px] text-gray-400 font-sans"></span>
)}
</button>
))}
{items.map((s, i) => {
const c = meta[i];
return (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 ${
i === active ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-1.5">
<span
className={`text-sm font-mono truncate ${
i === active ? 'text-blue-700' : 'text-gray-700'
}`}
>
{s}
</span>
<div className="flex-1" />
{/* 平台侧会话本侧还没有邮件线索:标出来,让人知道这一封是「接入」
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
{c?.source === 'platform' && (
<span
className="shrink-0 px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]"
title="平台侧已有的会话,本站还没有对应的邮件往来"
>
</span>
)}
{c?.source === 'new' && (
<span className="shrink-0 text-[10px] text-gray-400 font-sans"></span>
)}
{(c?.unread ?? 0) > 0 && (
<span className="shrink-0 px-1 py-0.5 rounded bg-red-500 text-white text-[9px]">
{c!.unread}
</span>
)}
</div>
{c?.title && c.source !== 'new' && (
<p className="text-[10px] text-gray-400 truncate mt-0.5">{c.title}</p>
)}
</button>
);
})}
</div>
)}
</div>

View File

@ -5,13 +5,17 @@ import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
/**
* 对话树视图(按方向分块加载)。
* 对话树视图(从线索根整树展开,分块加载)。
*
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
*
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
*
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
*
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
@ -20,29 +24,32 @@ import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
const [nodes, setNodes] = useState<ThreadNode[]>([]);
const [hidden, setHidden] = useState(0);
const [moreUp, setMoreUp] = useState(false);
const [moreDown, setMoreDown] = useState(false);
const [nextUp, setNextUp] = useState(0);
const [nextDown, setNextDown] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [nextOffset, setNextOffset] = useState(0);
const [err, setErr] = useState('');
const [initial, setInitial] = useState(true);
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinel = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
// 请求代次mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
const gen = useRef(0);
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
// 读 state 会双双看到 false 而并发发两个请求。
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
// 读 state 会看到旧的 false 而并发发两个请求。
const busy = useRef(false);
const sortNodes = (list: ThreadNode[]) =>
list
.slice()
.sort((a, b) =>
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
);
const merge = useCallback((incoming: ThreadNode[]) => {
setNodes(prev => {
const seen = new Set(prev.map(n => n.mail_id));
const added = incoming.filter(n => !seen.has(n.mail_id));
// 按相对深度排;同深度保持服务端给的时间序
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
});
}, []);
@ -55,15 +62,13 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
setInitial(true);
busy.current = true;
api
.getMailThread(mailID, { dir: 'around', limit: 40 })
.getMailThread(mailID, { offset: 0, limit: 60 })
.then(p => {
if (gen.current !== myGen) return;
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
setNodes(sortNodes(p.nodes));
setHidden(p.hidden);
setMoreUp(p.has_more_up);
setMoreDown(p.has_more_down);
setNextUp(p.next_up);
setNextDown(p.next_down);
setHasMore(p.has_more);
setNextOffset(p.next_offset);
})
.catch(e => {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
@ -76,85 +81,55 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
});
}, [mailID]);
const loadMore = useCallback(
async (dir: 'up' | 'down') => {
if (busy.current) return;
if (dir === 'up' && !moreUp) return;
if (dir === 'down' && !moreDown) return;
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
useEffect(() => {
if (initial || !anchorRef.current) return;
anchorRef.current.scrollIntoView({ block: 'center' });
}, [initial]);
const myGen = gen.current;
busy.current = true;
setLoading(true);
const loadMore = useCallback(async () => {
if (busy.current || !hasMore) return;
const myGen = gen.current;
busy.current = true;
setLoading(true);
try {
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
setHasMore(p.has_more);
setNextOffset(p.next_offset);
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
}, [mailID, merge, hasMore, nextOffset]);
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处
const el = scrollRef.current;
const beforeHeight = el?.scrollHeight ?? 0;
const beforeTop = el?.scrollTop ?? 0;
try {
const p = await api.getMailThread(mailID, {
dir,
offset: dir === 'up' ? nextUp : nextDown,
limit: 40
});
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
if (dir === 'up') {
setMoreUp(p.has_more_up);
setNextUp(p.next_up);
} else {
setMoreDown(p.has_more_down);
setNextDown(p.next_down);
}
if (dir === 'up' && el) {
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
requestAnimationFrame(() => {
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
});
}
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
},
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
);
// 两端各一个哨兵,进入视口就加载对应方向。
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
// 让加载在用户滑到边界前完成
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting) continue;
if (e.target === topSentinel.current) loadMore('up');
if (e.target === bottomSentinel.current) loadMore('down');
}
for (const e of entries) if (e.isIntersecting) loadMore();
},
{ root, rootMargin: '200px' }
);
if (topSentinel.current) obs.observe(topSentinel.current);
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
return () => obs.disconnect();
}, [loadMore]);
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
// 否则祖先未加载时首屏内容会整体缩进一大截。
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
{(moreUp || moreDown) && ',滑动加载更多'}
{hasMore && ',滑动加载更多'}
{hidden > 0 && `${hidden} 封无权查看`}
</span>
<div className="flex-1" />
@ -179,28 +154,23 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
{!initial && (
<>
<div ref={topSentinel} className="h-px" />
{moreUp && (
<button
onClick={() => loadMore('up')}
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div className="space-y-1.5">
{nodes.map(n => (
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
<Node
key={n.mail_id}
node={n}
anchorID={mailID}
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
/>
))}
</div>
{moreDown && (
{hasMore && (
<button
onClick={() => loadMore('down')}
onClick={loadMore}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div ref={bottomSentinel} className="h-px" />
@ -213,18 +183,22 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
function Node({
node,
baseDepth,
anchorID
anchorID,
anchorRef
}: {
node: ThreadNode;
baseDepth: number;
anchorID: string;
anchorRef?: React.RefObject<HTMLDivElement>;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
const ccCount = node.cc_list?.length ?? 0;
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
const isForward = node.subject.startsWith('Fwd: ');
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
const indent = Math.min(Math.max(node.depth, 0), 8) * 20;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
@ -233,7 +207,7 @@ function Node({
});
return (
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
@ -256,6 +230,11 @@ function Node({
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{isForward && (
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]">
</span>
)}
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
@ -279,6 +258,13 @@ function Node({
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && (
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
{node.cc_list.map(c => c.raw || c.name).join('、')}
</p>
)}
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}

View File

@ -114,27 +114,29 @@ export interface Mail {
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
/** 距**线索根**的层级0 = 根1 = 它的直接回复 */
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐 */
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
parent_hidden?: boolean;
body?: string;
}
export interface ThreadPage {
anchor_mail_id: string;
dir: 'around' | 'up' | 'down';
/** 线索根的 mail_id整棵树从它展开 */
root_mail_id: string;
/** 锚点距根的层数,用于高亮定位 */
anchor_depth: number;
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more_up: boolean;
has_more_down: boolean;
has_more: boolean;
/** 下一页 offset原样回传即可 */
next_up: number;
next_down: number;
next_offset: number;
}
/**
@ -205,9 +207,33 @@ export interface HumanSession {
export type SuggestKind = 'name' | 'path' | 'session';
/**
* 会话候选项的来源。
* mail 本侧邮件线索 —— 这个别名一定送得到
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
* new 新建会话的哨兵项
*/
export type SessionCandidateSource = 'mail' | 'platform' | 'new';
export interface SessionCandidate {
/** 填进 session 位的值 */
alias: string;
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
title?: string;
source: SessionCandidateSource;
/** 仅 mail 来源有意义 */
unread?: number;
}
export interface SuggestResult {
kind: SuggestKind;
suggestions: string[];
/**
* 带标题与来源的完整候选,与 suggestions 同序。
* 仅 kind === 'session' 时返回suggestions 保留纯字符串形式是为了
* 不打破已部署的前端与第三方客户端。
*/
candidates?: SessionCandidate[];
}
/** 系统初始化状态 */