Skip to content
Open
45 changes: 45 additions & 0 deletions docs/architecture/sidebar-thread-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 侧边栏父子 Thread 嵌套

Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 Thread 缩进显示在父 Thread 下面。父 Thread 前出现展开/折叠按钮。该功能只改变 Renderer 的呈现,不改变执行归属、Thread 身份、导航或官方 Codex 的列表结果。

## 数据来源

关系只读取已持久化的数据,不打开 Harness Session、不读取 Snapshot、不加载 Turn:

| 关系 | 来源 | 读取方式 |
| --- | --- | --- |
| 委派 | Mapping Store 中 Delegation 的 `parentHostThreadId` → `childHostThreadId` | Host 本地方法 `codexhost/thread/relation/list` |
| 外部 Harness Fork | 外部 Thread 记录的 `forkSource.hostThreadId` | 同上 |
| native Codex Fork | 官方 `thread/read`(`includeTurns: false`)返回的 `forkedFromId` | Renderer 通过既有 Host 通道请求,仅对 Host 报告为 Codex 所有、且没有委派关系的 Thread 读取 |

同一 Thread 同时有委派和 Fork 来源时,以委派为准。官方 Subagent(`parentThreadId` 或 `source.subAgent`)和外部 Subagent 记录由 Desktop 原生嵌套,这里不重复处理。SSH 共享 owner 上的 Thread 由其他 Host 持有,本机不报告其关系。

`codexhost/thread/relation/list` 是新增的最小只读方法,参数与 `codexhost/thread/ownership/list` 相同(1–100 个不重复 Thread ID),返回每个 Thread 的 `owner` 和可选 `parent: { threadId, relation: "delegation" | "fork" }`。它不经过官方 Codex。Delegation 此前未暴露给 Renderer,所以需要这个方法;ownership 结果是严格 schema,不在原方法上扩字段。旧 Host 返回方法不可用时,Renderer 退回 ownership 列表,只保留 native Fork 嵌套;stock Codex 两者都没有时,所有 Thread 视为 native。

## Renderer 绑定

`renderer-sidebar-thread-tree.ts` 与侧边栏 Agent 图标共用同一套绑定方式:

- 用 Fiber 中与行属性一致的 `conversationId` 识别 Thread(`threadIdFromSidebarRowElement`),按 Host 区分相同 ID。
- `MutationObserver` 只在侧边栏行、列表或本功能标记的节点变化时调度一次 `requestAnimationFrame` 全量扫描。React 重渲染或替换节点后,下一次扫描重新应用布局,不依赖一次性 DOM 修改。
- 不移动、不包裹、不删除原生节点。只在原生 `role="listitem"` 上设置 CSS `order`、行首缩进和折叠时的 `display: none`,并保存原值;没有嵌套的列表不改样式。React 的节点协调和原生点击导航保持不变。
- 跨列表嵌套:Desktop 把每个 Thread 只放进一个列表,新 Fork 不继承置顶,所以置顶 Thread 的 Fork 出现在“最近”或项目列表里,与父 Thread 不在同一列表。所有列表一起规划(`planSidebarThreadForest`):同列表内的父 Thread 优先;否则子 Thread 及其子树显示在根祖先所在的列表中。原生子行在原列表中以 `display: none` 隐藏,父列表末尾追加一个 CodexHost 自有的镜像节点(`data-codexhost-sidebar-thread-mirror`),用 CSS `order` 和缩进排到父 Thread 下面。
- 镜像是原生列表项的深拷贝,去掉 `id`、`draggable`、行识别属性(`data-app-action-sidebar-thread-row`/`-id`/`-host-id`)、键盘循环和拖放属性,保留选中、置顶等视觉属性;原生行标记或内容变化时重新拷贝。镜像上的 click、dblclick、auxclick、contextmenu 及 Enter/空格键在原生子行的对应节点上重放,导航、右键菜单和行内按钮仍由 Desktop 处理。只有在全部列表中只出现一次的 Thread 才会跨列表移动;跨列表的环保持原位。
- 只有值变化时才写样式或属性,扫描是幂等的,自身写入不会形成观察循环。
- 展开/折叠按钮插入在 `[data-thread-title-trigger]` 最前面,绝对定位并用负的行首外边距放到图标左侧的行首留白中,不占行内宽度,所以有子 Thread 的行与同层级行的图标对齐,子 Thread 相对父 Thread 图标恰好缩进一级(14 px)。按钮阻止事件冒泡到原生行,避免触发导航。折叠状态只保存在当前 Renderer 内存中。
- 关系结果按 Host 批量请求,单批最多 100 个。委派记录可能晚于子行出现,没有父 Thread 的结果在 1 s、3 s、10 s 后各重试一次;读取失败的行保持顶层。native Fork 读取并发上限为 4,结果在 Renderer 生命周期内缓存。
- Renderer 绑定刷新时,清除失败和“尚无父 Thread”的缓存;释放时恢复所有原始样式并移除按钮。

## 已知边界

- 置顶(Pinned)列表对「向外镜像」保留原生位置:单独置顶子 Thread 时,它留在置顶区作为独立行,不会被未置顶的祖先拽回「最近」并隐藏。置顶父 Thread 下的跨列表 Fork/委派子 Thread 仍会镜像进置顶列表。取消置顶子 Thread 后由 Desktop 把它移回原列表。若父 Thread 本身已置顶,同列表嵌套照常。
- 子 Thread 所在分组(例如「最近」)折叠后,若父 Thread 仍留在其他列表(置顶/项目),已创建的镜像会保留在父 Thread 下,折叠按钮与子树不随源行卸载而消失。冷启动时若「最近」已折叠且从未挂载过子行,仍需展开一次才能建立镜像(没有可克隆的源 DOM)。
- 父 Thread 尚未渲染(未加载到当前页,或所在分组已折叠)时,子 Thread 留在原位置;父 Thread 再次出现后自动嵌套。
- 镜像不承接拖放、悬停卡片和只响应 pointerdown 的交互。
- 视觉顺序由 CSS `order` 决定;依赖 DOM 顺序的键盘循环仍按原生顺序。
- 层级缩进最多 6 级,更深的层级按第 6 级显示。
- 新增 Host 方法需要重启 CodexHost 后生效;只更新 Renderer 时只有 native Fork 嵌套。

## 验证

`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、置顶父 Thread 的跨列表 Fork(含后出现的 Fork、折叠、父 Thread 消失、最近分组卸载后 sticky 镜像保留和释放镜像)、跨列表的子树、同列表优先、重复 Thread 和跨列表环、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
| [`architecture/external-thread-media.md`](architecture/external-thread-media.md) | 外部 Thread 中本地图片的 Desktop 加载条件、Agent 消息媒体路径改写和用户消息图片附件还原;修改媒体显示或排查图片不显示时阅读。 |
| [`architecture/delegation-titles.md`](architecture/delegation-titles.md) | 委派子会话标题、子任务显示名、创建快照与重试边界。 |
| [`architecture/delegation-thread-list.md`](architecture/delegation-thread-list.md) | 委派 CLI 列表的共享 catalog、独立来源分页、查询绑定与活列表一致性边界。 |
| [`architecture/sidebar-thread-tree.md`](architecture/sidebar-thread-tree.md) | 侧边栏按委派和 Fork 关系缩进父子 Thread 的数据来源、Renderer 绑定与边界。 |
| [`architecture/thread-watch.md`](architecture/thread-watch.md) | 一次性 Thread 停下通知(`thread watch`)的模型、结果、送达与边界;修改委派跟进或通知时阅读。 |
| [`architecture/external-clients.md`](architecture/external-clients.md) | 同用户 CLI 自动认证、命名客户端登记、本机监听、Thread 引用、事件日志与 MCP;修改外部接入时阅读。 |
| [`architecture/official-traffic-ownership.md`](architecture/official-traffic-ownership.md) | Host 只截获自身流量、其余原样转发官方的归属判别与例外;修改请求路由或参数校验时阅读。 |
Expand Down
24 changes: 24 additions & 0 deletions packages/host-runtime/src/app-server-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
threadCatalogReadParamsSchema,
} from "@codexhost/shared-contracts";
import { requestDesktopRemoteConnections } from "@codexhost/desktop-control";
import { listThreadRelations } from "./thread-relation-list.js";
import type { RuntimeMaintenance } from "./runtime-maintenance.js";
import { readThreadCatalog } from "./thread-catalog.js";
import { ExternalThreadPrewarms } from "./external-thread-prewarms.js";
Expand Down Expand Up @@ -119,6 +120,7 @@ import {
threadPermissionModeSelectParamsSchema,
threadThinkingSelectParamsSchema,
threadOwnershipListParamsSchema,
threadRelationListParamsSchema,
threadOwnershipListResultSchema,
threadSteeringInspectParamsSchema,
threadSteeringInspectResultSchema,
Expand Down Expand Up @@ -1011,6 +1013,7 @@ export class AppServerHost {
inspectThread: (request) => this.#inspectThread(request),
inspectThreadUsage: (request) => this.#inspectThreadUsage(request),
listThreadOwnership: (request) => this.#listThreadOwnership(request),
listThreadRelations: (request) => this.#listThreadRelations(request),
inspectThreadSteering: (request) => this.#inspectThreadSteering(request),
handleSharedQuestionRequest: (request) => this.#handleSharedQuestionRequest(request),
selectThreadModel: (request) => this.#selectThreadModel(request),
Expand Down Expand Up @@ -3291,6 +3294,27 @@ export class AppServerHost {
}
}

/** Read persisted sidebar parents without restoring a Session or calling official Codex. */
async #listThreadRelations(request: JsonRpcRequest): Promise<void> {
const params = threadRelationListParamsSchema.safeParse(request.params);
if (!params.success) {
await this.#writer.json(rpcError(request, -32602, "Invalid Thread relation-list params"));
return;
}
try {
const result = await listThreadRelations({
threadIds: params.data.threadIds,
source: this.#repository,
sharedThreadIds: await this.#options.sharedThreads?.ownership(params.data.threadIds),
});
await this.#writer.json(rpcEnvelope(request, { result: jsonValueSchema.parse(result) }));
} catch {
await this.#writer.json(
rpcError(request, -32081, "Thread relation metadata could not be read"),
);
}
}

/** Read the same delivery decision that Desktop `turn/steer` will follow. */
async #inspectThreadSteering(request: JsonRpcRequest): Promise<void> {
const params = threadSteeringInspectParamsSchema.safeParse(request.params);
Expand Down
6 changes: 6 additions & 0 deletions packages/host-runtime/src/desktop-thread-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@codexhost/protocol-core";
import {
THREAD_QUESTIONS_LIST_METHOD,
THREAD_RELATION_LIST_METHOD,
THREAD_QUESTION_RESPOND_METHOD,
harnessCommandsInspectParamsSchema,
type HarnessId,
Expand Down Expand Up @@ -95,6 +96,7 @@ export interface DesktopThreadRoutingHost {
inspectThread(request: JsonRpcRequest): Promise<void>;
inspectThreadUsage(request: JsonRpcRequest): Promise<void>;
listThreadOwnership(request: JsonRpcRequest): Promise<void>;
listThreadRelations(request: JsonRpcRequest): Promise<void>;
inspectThreadSteering(request: JsonRpcRequest): Promise<void>;
handleSharedQuestionRequest(request: JsonRpcRequest): Promise<void>;
selectThreadModel(request: JsonRpcRequest): Promise<void>;
Expand Down Expand Up @@ -220,6 +222,10 @@ export class DesktopThreadRouting {
await this.#host.listThreadOwnership(request);
return true;
}
if (request.method === THREAD_RELATION_LIST_METHOD) {
await this.#host.listThreadRelations(request);
return true;
}
if (
request.method === THREAD_QUESTIONS_LIST_METHOD ||
request.method === THREAD_QUESTION_RESPOND_METHOD
Expand Down
56 changes: 56 additions & 0 deletions packages/host-runtime/src/thread-relation-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { StoredDelegationRecordV1, StoredThreadRecordV1 } from "@codexhost/mapping-store";
import {
threadRelationListResultSchema,
type HostThreadId,
type ThreadRelation,
type ThreadRelationListResult,
} from "@codexhost/shared-contracts";

/** Mapping Store reads used by the sidebar relation list; nothing else is needed. */
export interface ThreadRelationSource {
find(hostThreadId: HostThreadId): Promise<StoredThreadRecordV1 | null>;
getDelegationByChild(childHostThreadId: HostThreadId): Promise<StoredDelegationRecordV1 | null>;
}

/**
* Read the persisted sidebar parent of each Thread without opening a Harness
* Session, reading a Snapshot, or calling official Codex.
*
* A Delegation record wins over a Fork source because it is the relation the
* user created explicitly. Subagent records are left to Desktop's native
* nesting. Shared Threads are owned by another Host, so their
* relation is not known here and is reported as absent.
*/
export async function listThreadRelations(input: {
threadIds: readonly HostThreadId[];
source: ThreadRelationSource;
sharedThreadIds?: ReadonlySet<string> | ReadonlyMap<string, unknown> | undefined;
}): Promise<ThreadRelationListResult> {
const threads = await Promise.all(
input.threadIds.map(async (threadId): Promise<ThreadRelation> => {
if (input.sharedThreadIds?.has(threadId)) {
return { threadId, owner: "external", parent: null };
}
const [delegation, record] = await Promise.all([
input.source.getDelegationByChild(threadId),
input.source.find(threadId),
]);
const owner = record ? "external" : "codex";
// Desktop already nests Subagents through their projected parent.
if (record?.subagent) return { threadId, owner, parent: null };
if (delegation && delegation.parentHostThreadId !== threadId) {
return {
threadId,
owner,
parent: { threadId: delegation.parentHostThreadId, relation: "delegation" },
};
}
const forkParent = record?.forkSource?.hostThreadId;
if (forkParent && forkParent !== threadId) {
return { threadId, owner, parent: { threadId: forkParent, relation: "fork" } };
}
return { threadId, owner, parent: null };
}),
);
return threadRelationListResultSchema.parse({ threads });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { harnessIdSchema, hostThreadIdSchema } from "@codexhost/shared-contracts";

import {
FailingOwnershipMappingStore,
createFixture,
requestId,
startPiThread,
stopFixture,
writeRequest,
} from "./app-server-host-fixture.js";

describe("AppServerHost sidebar Thread relations", () => {
it("lists persisted Delegation parents locally without official Codex traffic", async () => {
const fixture = createFixture();
const childThreadId = await startPiThread(fixture);
await fixture.mappingStore.createDelegation({
delegationId: hostThreadIdSchema.parse("relation-delegation"),
parentHostThreadId: hostThreadIdSchema.parse("official-parent"),
childHostThreadId: hostThreadIdSchema.parse(childThreadId),
sourceHarnessId: harnessIdSchema.parse("codex"),
targetHarnessId: harnessIdSchema.parse("pi"),
status: "running",
taskDigest: "a".repeat(64),
});
const officialWrite = vi.fn();
fixture.official.stdin.on("data", officialWrite);

writeRequest(fixture.desktopInput, {
id: 51,
method: "codexhost/thread/relation/list",
params: { threadIds: ["official-parent", childThreadId] },
});

await expect(fixture.collector.waitFor((message) => requestId(message, 51))).resolves.toEqual({
id: 51,
result: {
threads: [
{ threadId: "official-parent", owner: "codex", parent: null },
{
threadId: childThreadId,
owner: "external",
parent: { threadId: "official-parent", relation: "delegation" },
},
],
},
});
expect(officialWrite).not.toHaveBeenCalled();
await stopFixture(fixture);
});

it("rejects invalid or unreadable relation-list metadata locally", async () => {
const directory = mkdtempSync(path.join(tmpdir(), "codexhost-host-test-"));
const mappingStore = new FailingOwnershipMappingStore({ directory });
const fixture = createFixture({ mappingStore, mappingStoreDirectory: directory });
await fixture.ready;
const officialWrite = vi.fn();
fixture.official.stdin.on("data", officialWrite);

writeRequest(fixture.desktopInput, {
id: 52,
method: "codexhost/thread/relation/list",
params: { threadIds: ["duplicate", "duplicate"] },
});
await expect(
fixture.collector.waitFor((message) => requestId(message, 52)),
).resolves.toMatchObject({ error: { code: -32602 } });

writeRequest(fixture.desktopInput, {
id: 53,
method: "codexhost/thread/relation/list",
params: { threadIds: ["unreadable-thread"] },
});
await expect(
fixture.collector.waitFor((message) => requestId(message, 53)),
).resolves.toMatchObject({ error: { code: -32081 } });
expect(officialWrite).not.toHaveBeenCalled();
await stopFixture(fixture);
});
});
Loading
Loading