From 58aec3265d6e54249a5b8010413e6dcecd4d0ea3 Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Tue, 3 Nov 2026 01:23:23 +0800 Subject: [PATCH 1/7] feat(renderer): nest delegated and forked threads in the sidebar Indent CodexHost-dispatched child threads and forked threads under their parent in the Codex Desktop sidebar, with an expand/collapse toggle on parents that have children. - Add the read-only `codexhost/thread/relation/list` host method. It resolves the parent from the persisted Delegation record first and the recorded fork source second, and never writes to the official App Server. - Read native Codex fork parents from `thread/read` (`forkedFromId`) for Codex-owned threads, skipping official subagents that Desktop already nests. - Install a sidebar thread tree on the existing renderer binding. It reapplies CSS order, indentation and visibility to native list items on every observed mutation, so React re-renders cannot drop the layout, and it never moves or clones native nodes. Child rows keep native navigation; the toggle stops propagation. - Fall back to ownership data when the host predates the new method. Fixes #708 Signed-off-by: Vincent <1729065730@qq.com> --- docs/architecture/sidebar-thread-tree.md | 40 ++ docs/index.md | 1 + packages/host-runtime/src/app-server-host.ts | 28 + .../host-runtime/src/thread-relation-list.ts | 56 ++ .../app-server-host.thread-relations.test.ts | 83 +++ .../test/thread-relation-list.test.ts | 96 +++ .../src/renderer-binding-probe.ts | 23 +- .../src/renderer-model-client.ts | 57 +- .../src/renderer-native-thread.ts | 25 + .../src/renderer-sidebar-thread-tree.ts | 626 ++++++++++++++++++ ...enderer-binding-probe-host-catalog.test.ts | 4 + .../test/renderer-model-client.test.ts | 90 +++ .../test/renderer-sidebar-thread-tree.test.ts | 410 ++++++++++++ packages/shared-contracts/src/index.ts | 1 + .../shared-contracts/src/thread-relations.ts | 86 +++ 15 files changed, 1623 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/sidebar-thread-tree.md create mode 100644 packages/host-runtime/src/thread-relation-list.ts create mode 100644 packages/host-runtime/test/app-server-host.thread-relations.test.ts create mode 100644 packages/host-runtime/test/thread-relation-list.test.ts create mode 100644 packages/renderer-extension/src/renderer-sidebar-thread-tree.ts create mode 100644 packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts create mode 100644 packages/shared-contracts/src/thread-relations.ts diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md new file mode 100644 index 000000000..79fc86a2f --- /dev/null +++ b/docs/architecture/sidebar-thread-tree.md @@ -0,0 +1,40 @@ +# 侧边栏父子 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 的节点协调和原生点击导航保持不变。 +- 只有值变化时才写样式或属性,扫描是幂等的,自身写入不会形成观察循环。 +- 展开/折叠按钮插入在 `[data-thread-title-trigger]` 最前面,阻止事件冒泡到原生行,避免触发导航。折叠状态只保存在当前 Renderer 内存中。 +- 关系结果按 Host 批量请求,单批最多 100 个。委派记录可能晚于子行出现,没有父 Thread 的结果在 1 s、3 s、10 s 后各重试一次;读取失败的行保持顶层。native Fork 读取并发上限为 4,结果在 Renderer 生命周期内缓存。 +- Renderer 绑定刷新时,清除失败和“尚无父 Thread”的缓存;释放时恢复所有原始样式并移除按钮。 + +## 已知边界 + +- 父子必须在同一个原生列表中才会嵌套。父 Thread 在其他项目分组、置顶列表或尚未加载到当前页时,子 Thread 留在原位置。 +- 视觉顺序由 CSS `order` 决定;依赖 DOM 顺序的键盘循环仍按原生顺序。 +- 层级缩进最多 6 级,更深的层级按第 6 级显示。 +- 新增 Host 方法需要重启 CodexHost 后生效;只更新 Renderer 时只有 native Fork 嵌套。 + +## 验证 + +`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、跨列表、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。 diff --git a/docs/index.md b/docs/index.md index dfa09f6a9..0889fdb3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,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/official-traffic-ownership.md`](architecture/official-traffic-ownership.md) | Host 只截获自身流量、其余原样转发官方的归属判别与例外;修改请求路由或参数校验时阅读。 | | [`architecture/app-server-transport.md`](architecture/app-server-transport.md) | 原生大历史响应的 WebSocket 与 JSONL 传输边界;排查任务加载、消息大小和转发性能时阅读。 | diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index a9d77bd19..653c408c0 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -16,6 +16,7 @@ import { remoteUpdateParamsSchema, } 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 { ExternalThreadPrewarms } from "./external-thread-prewarms.js"; import { externalTurnInputText } from "./external-turn-input.js"; @@ -140,6 +141,8 @@ import { threadPermissionModeSelectParamsSchema, threadThinkingSelectParamsSchema, threadOwnershipListParamsSchema, + threadRelationListParamsSchema, + THREAD_RELATION_LIST_METHOD, threadOwnershipListResultSchema, threadSteeringInspectParamsSchema, threadSteeringInspectResultSchema, @@ -1731,6 +1734,10 @@ export class AppServerHost { await this.#listThreadOwnership(request); return; } + if (request.method === THREAD_RELATION_LIST_METHOD) { + await this.#listThreadRelations(request); + return; + } if ( request.method === THREAD_QUESTIONS_LIST_METHOD || request.method === THREAD_QUESTION_RESPOND_METHOD @@ -3546,6 +3553,27 @@ export class AppServerHost { } } + /** Read persisted sidebar parents without restoring a Session or calling official Codex. */ + async #listThreadRelations(request: JsonRpcRequest): Promise { + 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 { const params = threadSteeringInspectParamsSchema.safeParse(request.params); diff --git a/packages/host-runtime/src/thread-relation-list.ts b/packages/host-runtime/src/thread-relation-list.ts new file mode 100644 index 000000000..6c3654feb --- /dev/null +++ b/packages/host-runtime/src/thread-relation-list.ts @@ -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; + getDelegationByChild(childHostThreadId: HostThreadId): Promise; +} + +/** + * 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 | ReadonlyMap | undefined; +}): Promise { + const threads = await Promise.all( + input.threadIds.map(async (threadId): Promise => { + 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 }); +} diff --git a/packages/host-runtime/test/app-server-host.thread-relations.test.ts b/packages/host-runtime/test/app-server-host.thread-relations.test.ts new file mode 100644 index 000000000..92a0be95c --- /dev/null +++ b/packages/host-runtime/test/app-server-host.thread-relations.test.ts @@ -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); + }); +}); diff --git a/packages/host-runtime/test/thread-relation-list.test.ts b/packages/host-runtime/test/thread-relation-list.test.ts new file mode 100644 index 000000000..d831f6d0c --- /dev/null +++ b/packages/host-runtime/test/thread-relation-list.test.ts @@ -0,0 +1,96 @@ +import type { StoredDelegationRecordV1, StoredThreadRecordV1 } from "@codexhost/mapping-store"; +import { hostThreadIdSchema, type HostThreadId } from "@codexhost/shared-contracts"; +import { describe, expect, it, vi } from "vitest"; + +import { listThreadRelations, type ThreadRelationSource } from "../src/thread-relation-list.js"; + +const id = (value: string): HostThreadId => hostThreadIdSchema.parse(value); + +function sourceWith(options: { + records?: Record>; + delegations?: Record; +}): ThreadRelationSource { + return { + find: vi.fn(async (threadId: HostThreadId) => { + const record = options.records?.[threadId]; + return record ? ({ hostThreadId: threadId, ...record } as StoredThreadRecordV1) : null; + }), + getDelegationByChild: vi.fn(async (threadId: HostThreadId) => { + const parent = options.delegations?.[threadId]; + return parent + ? ({ + childHostThreadId: threadId, + parentHostThreadId: id(parent), + } as StoredDelegationRecordV1) + : null; + }), + }; +} + +describe("listThreadRelations", () => { + it("reports Delegation parents before Fork sources and keeps request order", async () => { + const source = sourceWith({ + records: { + delegated: { forkSource: { hostThreadId: id("fork-parent") } as never }, + forked: { forkSource: { hostThreadId: id("fork-parent") } as never }, + external: {}, + }, + delegations: { delegated: "delegation-parent", "codex-child": "external" }, + }); + + await expect( + listThreadRelations({ + threadIds: [id("delegated"), id("forked"), id("external"), id("codex-child"), id("plain")], + source, + }), + ).resolves.toEqual({ + threads: [ + { + threadId: "delegated", + owner: "external", + parent: { threadId: "delegation-parent", relation: "delegation" }, + }, + { + threadId: "forked", + owner: "external", + parent: { threadId: "fork-parent", relation: "fork" }, + }, + { threadId: "external", owner: "external", parent: null }, + { + threadId: "codex-child", + owner: "codex", + parent: { threadId: "external", relation: "delegation" }, + }, + { threadId: "plain", owner: "codex", parent: null }, + ], + }); + }); + + it("leaves Subagents, shared Threads, and self references at the top level", async () => { + const source = sourceWith({ + records: { + subagent: { + subagent: { parentHostThreadId: id("parent") } as never, + forkSource: { hostThreadId: id("parent") } as never, + }, + self: { forkSource: { hostThreadId: id("self") } as never }, + }, + delegations: { shared: "parent" }, + }); + + await expect( + listThreadRelations({ + threadIds: [id("subagent"), id("shared"), id("self")], + source, + sharedThreadIds: new Map([["shared", "peer"]]), + }), + ).resolves.toEqual({ + threads: [ + { threadId: "subagent", owner: "external", parent: null }, + { threadId: "shared", owner: "external", parent: null }, + { threadId: "self", owner: "external", parent: null }, + ], + }); + expect(source.getDelegationByChild).not.toHaveBeenCalledWith("shared"); + }); +}); diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts index 2cc9cbbf8..e7bd132de 100644 --- a/packages/renderer-extension/src/renderer-binding-probe.ts +++ b/packages/renderer-extension/src/renderer-binding-probe.ts @@ -99,6 +99,7 @@ import { installRendererSidebarAgentIcons, SIDEBAR_THREAD_ROW_SELECTOR, } from "./renderer-sidebar-agent-icons.js"; +import { installRendererSidebarThreadTree } from "./renderer-sidebar-thread-tree.js"; import { rendererHarnessCommandExecutesDirectly, routeRendererHarnessCommandSelection, @@ -836,6 +837,9 @@ export function installRendererBindingProbe( }, getLocalAgent: localAgentForSidebarThread, }); + const sidebarThreadTree = installRendererSidebarThreadTree({ + getClient: (hostId) => modelClientForHost(hostId), + }); let connectionDiagnostics: RendererConnectionDiagnostics | null = null; const remoteConnections = createRemoteConnectionsControl(window, modelClientForHost); const usageStatistics = createRendererUsageStatisticsSource({ @@ -1466,6 +1470,7 @@ export function installRendererBindingProbe( renderMounted(mounted); if (mounted.ownershipStatus !== "error") void refreshCommands(mounted); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); // Ownership can already contain Usage, but only usage inspection tells // us whether this (possibly unknown) plugin also supplies account quota. if (mounted.ownershipStatus !== "error") void refreshThreadUsage(mounted); @@ -1524,6 +1529,7 @@ export function installRendererBindingProbe( void loadThreadOwnership(mounted); } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); return true; }; @@ -2275,6 +2281,7 @@ export function installRendererBindingProbe( void refreshCommands(mounted); } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); return switched; } catch { adapterStatus = { @@ -2437,6 +2444,7 @@ export function installRendererBindingProbe( ); controller.setEnabledAgents(enabledAgents); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); for (const mounted of mountedByComposer.values()) { if (mounted.hostId === hostId || hostId === "local") renderMounted(mounted); } @@ -2601,7 +2609,10 @@ export function installRendererBindingProbe( renderMounted(mounted); void loadThreadOwnership(mounted); } - if (changed) sidebarAgentIcons.refresh(); + if (changed) { + sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); + } }; function reconcileHarnessAvailabilityHost(retryDraftUsage = false): void { @@ -2651,7 +2662,10 @@ export function installRendererBindingProbe( } } // A reconnect can replace the client without changing the Composer's Host ID. - if (connectionsChanged) sidebarAgentIcons.refresh(); + if (connectionsChanged) { + sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); + } const hostId = activeModelHostId(); if ( !hostId || @@ -2876,6 +2890,7 @@ export function installRendererBindingProbe( } renderMounted(mounted); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); if (threadIdFromComposerModelTarget(modelTarget) && !inherited) { void loadThreadOwnership(mounted); } else if ( @@ -3176,6 +3191,7 @@ export function installRendererBindingProbe( } } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); void refreshHarnessAvailability(); @@ -3196,6 +3212,7 @@ export function installRendererBindingProbe( reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); void refreshHarnessAvailabilityForHost("local"); void refreshHarnessAvailability(); } @@ -3434,6 +3451,7 @@ export function installRendererBindingProbe( harnessAvailabilityByHost.clear(); activeAvailabilityHostId = "local"; sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); void refreshHarnessAvailabilityForHost("local"); reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); @@ -3476,6 +3494,7 @@ export function installRendererBindingProbe( disposeReasoningSoftWrap(); delegationMention?.dispose(); sidebarAgentIcons.dispose(); + sidebarThreadTree.dispose(); nativeScheduleControls.dispose(); settingsLifecycle.dispose(); document.removeEventListener("beforeinput", onBeforeInput, true); diff --git a/packages/renderer-extension/src/renderer-model-client.ts b/packages/renderer-extension/src/renderer-model-client.ts index 3f5d8fb76..f9b1be02c 100644 --- a/packages/renderer-extension/src/renderer-model-client.ts +++ b/packages/renderer-extension/src/renderer-model-client.ts @@ -77,6 +77,9 @@ import { threadThinkingSelectParamsSchema, threadOwnershipListParamsSchema, threadOwnershipListResultSchema, + threadRelationListParamsSchema, + threadRelationListResultSchema, + THREAD_RELATION_LIST_METHOD, threadUsageInspectionParamsSchema, threadUsageInspectionSchema, updateCheckResultSchema, @@ -104,6 +107,9 @@ import { type ThreadThinkingSelectParams, type ThreadOwnershipListParams, type ThreadOwnershipListResult, + type HostThreadId, + type ThreadRelationListParams, + type ThreadRelationListResult, type ThreadUsageInspection, type ThreadUsageInspectionParams, type UpdateCheckResult, @@ -118,7 +124,7 @@ import { RendererMethodUnavailableError, type RendererRequestOptions, } from "./renderer-request-sender.js"; -import { verifyNativeCodexThread } from "./renderer-native-thread.js"; +import { readNativeThreadForkSource, verifyNativeCodexThread } from "./renderer-native-thread.js"; import { createRendererSessionImportClient, type RendererSessionImportClient, @@ -223,6 +229,10 @@ export interface RendererModelClient extends Partial; executeThreadCommand(input: ThreadCommandExecuteParams): Promise; listThreadOwnership(input: ThreadOwnershipListParams): Promise; + /** Persisted sidebar parents; optional because older Hosts and stock Codex lack it. */ + listThreadRelations?(input: ThreadRelationListParams): Promise; + /** Native Codex `thread/read` Fork source, metadata only. */ + readNativeThreadForkSource?(threadId: HostThreadId): Promise; inspectThreadUsage(input: ThreadUsageInspectionParams): Promise; subscribeThreadUsage?(listener: (update: ThreadUsageInspection) => void): () => void; selectThreadModel(input: ThreadModelSelectParams): Promise; @@ -480,6 +490,51 @@ export function createRendererModelClient( } return result; }, + async listThreadRelations(input: ThreadRelationListParams): Promise { + const params = threadRelationListParamsSchema.parse(input); + let result: ThreadRelationListResult; + try { + result = threadRelationListResultSchema.parse( + await manager.sendRequest(THREAD_RELATION_LIST_METHOD, params), + ); + } catch (error) { + if (!(error instanceof RendererMethodUnavailableError)) throw error; + // Older Hosts have no relation store access; keep only ownership so + // native Fork lookup is limited to Codex Threads. Stock Codex has + // neither method and every Thread is native. + let owners: ThreadOwnershipListResult["threads"] | null = null; + try { + owners = threadOwnershipListResultSchema.parse( + await manager.sendRequest(THREAD_OWNERSHIP_LIST_METHOD, params), + ).threads; + } catch (ownershipError) { + if (!(ownershipError instanceof RendererMethodUnavailableError)) throw ownershipError; + } + result = { + threads: params.threadIds.map((threadId, index) => ({ + threadId, + owner: owners?.[index]?.threadId === threadId ? owners[index].owner : "codex", + parent: null, + })), + }; + if (owners && owners.some((thread, index) => thread.threadId !== params.threadIds[index])) + throw new Error("Thread ownership-list result does not match the requested IDs"); + } + if ( + result.threads.length !== params.threadIds.length || + result.threads.some((thread, index) => thread.threadId !== params.threadIds[index]) + ) { + throw new Error("Thread relation-list result does not match the requested IDs"); + } + return result; + }, + async readNativeThreadForkSource(threadId: HostThreadId): Promise { + const parent = await readNativeThreadForkSource( + manager.sendRequest, + hostThreadIdSchema.parse(threadId), + ); + return parent === null ? null : hostThreadIdSchema.parse(parent); + }, inspectThreadUsage, subscribeThreadUsage(listener: (update: ThreadUsageInspection) => void): () => void { const notifications = notificationTarget(source); diff --git a/packages/renderer-extension/src/renderer-native-thread.ts b/packages/renderer-extension/src/renderer-native-thread.ts index 4355f3aa4..557a6a4e4 100644 --- a/packages/renderer-extension/src/renderer-native-thread.ts +++ b/packages/renderer-extension/src/renderer-native-thread.ts @@ -28,3 +28,28 @@ export async function verifyNativeCodexThread( throw new Error("Native Thread response cannot establish Codex ownership"); } } + +/** + * Read the Thread a native Codex Thread was forked from, using the existing + * metadata-only `thread/read` request. Returns null when the Thread is not a + * Fork or the response cannot be matched to the requested Thread. + * @param sendRequest Sends a Desktop app-server request. + * @param threadId The native Thread identity being read. + */ +export async function readNativeThreadForkSource( + sendRequest: (method: string, params: unknown) => Promise | unknown, + threadId: string, +): Promise { + const native = await sendRequest("thread/read", { threadId, includeTurns: false }); + const thread = isRecord(native) ? native.thread : null; + if (!isRecord(thread) || thread.id !== threadId) { + throw new Error("Native Thread response does not match the requested Thread"); + } + // Codex Subagents may also carry a Fork source; Desktop nests them natively. + if (typeof thread.parentThreadId === "string" && thread.parentThreadId) return null; + if (isRecord(thread.source) && "subAgent" in thread.source) return null; + const forkedFromId = thread.forkedFromId; + return typeof forkedFromId === "string" && forkedFromId && forkedFromId !== threadId + ? forkedFromId + : null; +} diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts new file mode 100644 index 000000000..1dc95d03d --- /dev/null +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -0,0 +1,626 @@ +import { + THREAD_RELATION_LIST_MAX_LENGTH, + hostThreadIdSchema, + type HostThreadId, +} from "@codexhost/shared-contracts"; + +import type { RendererModelClient } from "./renderer-model-client.js"; +import { RendererMethodUnavailableError } from "./renderer-request-sender.js"; +import { + SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + SIDEBAR_THREAD_ROW_SELECTOR, + threadIdFromSidebarRowElement, +} from "./renderer-sidebar-agent-icons.js"; + +export const SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE = "data-codexhost-sidebar-thread-tree"; +export const SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE = "data-codexhost-sidebar-thread-toggle"; +export const SIDEBAR_THREAD_TREE_INDENT_PX = 14; +const SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH = 6; +const RELATION_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; +const NATIVE_FORK_READ_CONCURRENCY = 4; + +/** One Sidebar list entry: its Thread key and the key of its persisted parent. */ +export interface SidebarThreadTreeEntry { + key: string | null; + parentKey: string | null; +} + +export interface SidebarThreadTreeToggle { + collapsed: boolean; + childCount: number; + onToggle(): void; +} + +export interface SidebarThreadTreeLayout { + /** CSS `order` inside the native list; the DOM itself is never moved. */ + order: number; + depth: number; + hidden: boolean; + toggle: SidebarThreadTreeToggle | null; +} + +export type SidebarThreadTreeLayoutPlan = Omit & { + childCount: number; + collapsed: boolean; +}; + +/** + * Order one native list as a tree. Children follow their parent depth-first, + * keeping native order among siblings. A parent outside this list, a + * duplicate key, or a cycle leaves the entry at the top level. + * @returns One plan per entry, or null when nothing in the list is nested. + */ +export function layoutSidebarThreadTree( + entries: readonly SidebarThreadTreeEntry[], + collapsed: ReadonlySet = new Set(), +): SidebarThreadTreeLayoutPlan[] | null { + const indexByKey = new Map(); + entries.forEach(({ key }, index) => { + if (key !== null && !indexByKey.has(key)) indexByKey.set(key, index); + }); + const parent: (number | undefined)[] = entries.map(({ key, parentKey }, index) => { + if (key === null || parentKey === null || indexByKey.get(key) !== index) return undefined; + const parentIndex = indexByKey.get(parentKey); + return parentIndex === index ? undefined : parentIndex; + }); + for (let index = 0; index < parent.length; index += 1) { + let ancestor = parent[index]; + for (let steps = 0; ancestor !== undefined && steps < parent.length; steps += 1) { + if (ancestor === index) { + parent[index] = undefined; + break; + } + ancestor = parent[ancestor]; + } + } + if (parent.every((value) => value === undefined)) return null; + + const children = entries.map((): number[] => []); + parent.forEach((parentIndex, index) => { + if (parentIndex !== undefined) children[parentIndex]?.push(index); + }); + const plans: SidebarThreadTreeLayoutPlan[] = entries.map(({ key }, index) => ({ + order: 0, + depth: 0, + hidden: false, + childCount: children[index]?.length ?? 0, + collapsed: key !== null && (children[index]?.length ?? 0) > 0 && collapsed.has(key), + })); + let order = 0; + const visit = (index: number, depth: number, hidden: boolean): void => { + const plan = plans[index]; + if (!plan) return; + plan.order = order; + order += 1; + plan.depth = depth; + plan.hidden = hidden; + for (const child of children[index] ?? []) visit(child, depth + 1, hidden || plan.collapsed); + }; + parent.forEach((parentIndex, index) => { + if (parentIndex === undefined) visit(index, 0, false); + }); + return plans; +} + +export interface SidebarThreadTreeItem { + /** The Host Thread shown by this list item, or null for any other entry. */ + thread(): { hostId: string; threadId: HostThreadId } | null; + apply(layout: SidebarThreadTreeLayout | null): void; +} + +export interface SidebarThreadTreeList { + /** Every direct native list entry, in DOM order. */ + items(): readonly SidebarThreadTreeItem[]; +} + +export interface SidebarThreadTreeDom { + lists(): readonly SidebarThreadTreeList[]; + /** Report any change that can affect Sidebar rows or the applied layout. */ + observe(onChange: () => void): () => void; + clear(): void; +} + +export interface RendererSidebarThreadTree { + refresh(): void; + dispose(): void; +} + +interface SavedItemStyle { + order: string; + marginInlineStart: string; + display: string; +} + +const savedItemStyles = new WeakMap(); +const toggleHandlers = new WeakMap void>(); + +function chevronIcon(document: Document): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("width", "12"); + svg.setAttribute("height", "12"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M6 4l4 4-4 4"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.5"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.append(path); + return svg; +} + +function stopRowEvent(event: Event): void { + // The toggle sits inside the native row button; keep the row's own click, + // drag, and context handlers from seeing toggle input. + event.stopPropagation(); +} + +function createToggle(document: Document): HTMLElement { + const toggle = document.createElement("span"); + toggle.setAttribute(SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE, ""); + toggle.setAttribute("role", "button"); + toggle.tabIndex = 0; + toggle.style.display = "inline-flex"; + toggle.style.alignItems = "center"; + toggle.style.justifyContent = "center"; + toggle.style.width = "14px"; + toggle.style.height = "14px"; + toggle.style.flex = "none"; + toggle.style.cursor = "pointer"; + toggle.style.borderRadius = "3px"; + toggle.style.opacity = "0.75"; + toggle.append(chevronIcon(document)); + for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "dblclick"]) { + toggle.addEventListener(type, stopRowEvent); + } + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + event.preventDefault(); + toggleHandlers.get(toggle)?.(); + }); + toggle.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.stopPropagation(); + event.preventDefault(); + toggleHandlers.get(toggle)?.(); + }); + return toggle; +} + +function setStyle(element: HTMLElement, property: keyof SavedItemStyle, value: string): void { + if (element.style[property] !== value) element.style[property] = value; +} + +class BrowserSidebarThreadTreeItem implements SidebarThreadTreeItem { + constructor(private readonly element: HTMLElement) {} + + #row(): HTMLElement | null { + const rows = this.element.querySelectorAll(SIDEBAR_THREAD_ROW_SELECTOR); + return rows.length === 1 ? (rows[0] ?? null) : null; + } + + thread(): { hostId: string; threadId: HostThreadId } | null { + const row = this.#row(); + const hostId = row?.getAttribute(SIDEBAR_THREAD_HOST_ID_ATTRIBUTE); + const threadId = hostThreadIdSchema.safeParse(row && threadIdFromSidebarRowElement(row)); + return hostId && threadId.success ? { hostId, threadId: threadId.data } : null; + } + + apply(layout: SidebarThreadTreeLayout | null): void { + const element = this.element; + if (!layout) { + clearSidebarThreadTreeItem(element); + return; + } + if (!savedItemStyles.has(element)) { + savedItemStyles.set(element, { + order: element.style.order, + marginInlineStart: element.style.marginInlineStart, + display: element.style.display, + }); + } + const saved = savedItemStyles.get(element); + if (!element.hasAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE)) + element.setAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE, ""); + setStyle(element, "order", String(layout.order)); + const indent = Math.min(layout.depth, SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH); + setStyle( + element, + "marginInlineStart", + indent > 0 ? `${indent * SIDEBAR_THREAD_TREE_INDENT_PX}px` : (saved?.marginInlineStart ?? ""), + ); + setStyle(element, "display", layout.hidden ? "none" : (saved?.display ?? "")); + this.#applyToggle(layout.toggle); + } + + #applyToggle(toggle: SidebarThreadTreeToggle | null): void { + const existing = [ + ...this.element.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`), + ]; + const titleTrigger = this.#row()?.querySelector("[data-thread-title-trigger]"); + if (!toggle || !titleTrigger) { + for (const node of existing) node.remove(); + return; + } + let node = existing.find((candidate) => candidate.parentElement === titleTrigger); + for (const stale of existing) if (stale !== node) stale.remove(); + if (!node) { + node = createToggle(this.element.ownerDocument); + titleTrigger.prepend(node); + } + toggleHandlers.set(node, toggle.onToggle); + const expanded = toggle.collapsed ? "false" : "true"; + if (node.getAttribute("aria-expanded") !== expanded) + node.setAttribute("aria-expanded", expanded); + const label = `${toggle.collapsed ? "Expand" : "Collapse"} ${toggle.childCount} child ${ + toggle.childCount === 1 ? "thread" : "threads" + }`; + if (node.getAttribute("aria-label") !== label) { + node.setAttribute("aria-label", label); + node.title = label; + } + const svg = node.querySelector("svg"); + const transform = toggle.collapsed ? "" : "rotate(90deg)"; + if (svg && svg.style.transform !== transform) svg.style.transform = transform; + } +} + +function clearSidebarThreadTreeItem(element: HTMLElement): void { + for (const node of element.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`)) { + node.remove(); + } + const saved = savedItemStyles.get(element); + if (!element.hasAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE) && !saved) return; + element.removeAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE); + setStyle(element, "order", saved?.order ?? ""); + setStyle(element, "marginInlineStart", saved?.marginInlineStart ?? ""); + setStyle(element, "display", saved?.display ?? ""); + savedItemStyles.delete(element); +} + +function sidebarListItem(row: Element): HTMLElement | null { + const item = row.closest('[role="listitem"]'); + return item?.parentElement?.getAttribute("role") === "list" ? item : null; +} + +class BrowserSidebarThreadTreeDom implements SidebarThreadTreeDom { + readonly #items = new WeakMap(); + + constructor(private readonly root: ParentNode & Node) {} + + #item(element: HTMLElement): BrowserSidebarThreadTreeItem { + let item = this.#items.get(element); + if (!item) this.#items.set(element, (item = new BrowserSidebarThreadTreeItem(element))); + return item; + } + + lists(): readonly SidebarThreadTreeList[] { + const lists = new Set(); + for (const row of this.root.querySelectorAll(SIDEBAR_THREAD_ROW_SELECTOR)) { + const list = sidebarListItem(row)?.parentElement; + if (list) lists.add(list); + } + // Lists that lost their last thread row must still drop old layout. + for (const item of this.root.querySelectorAll( + `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`, + )) { + if (item.parentElement) lists.add(item.parentElement); + else clearSidebarThreadTreeItem(item); + } + return [...lists].map((list) => ({ + items: () => + [...list.children] + .filter((child): child is HTMLElement => child instanceof HTMLElement) + .map((child) => this.#item(child)), + })); + } + + observe(onChange: () => void): () => void { + const affectsSidebar = (node: Node | null): boolean => { + if (!(node instanceof Element)) return false; + if (node.closest(SIDEBAR_THREAD_ROW_SELECTOR)) return true; + if (node.closest(`[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`)) return true; + if ( + node.matches(SIDEBAR_THREAD_ROW_SELECTOR) || + node.querySelector(SIDEBAR_THREAD_ROW_SELECTOR) + ) + return true; + return ( + node.getAttribute("role") === "list" && + node.querySelector(`[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`) !== null + ); + }; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + affectsSidebar(mutation.target) || + [...mutation.addedNodes].some(affectsSidebar) || + [...mutation.removedNodes].some(affectsSidebar) + ) { + onChange(); + return; + } + } + }); + observer.observe(this.root, { + attributes: true, + attributeFilter: [ + "data-app-action-sidebar-thread-row", + "data-app-action-sidebar-thread-id", + SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + "role", + "style", + ], + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + } + + clear(): void { + for (const item of this.root.querySelectorAll( + `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`, + )) { + clearSidebarThreadTreeItem(item); + } + for (const node of this.root.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`)) { + node.remove(); + } + } +} + +/** + * Nest Sidebar Threads under their Delegation or Fork parent. Layout is CSS on + * the native list items, re-applied after every React update, so selection, + * navigation, rename, pin, and archive stay native. + */ +export function installRendererSidebarThreadTree(options: { + getClient(hostId: string): RendererModelClient | null; + dom?: SidebarThreadTreeDom; +}): RendererSidebarThreadTree { + const dom = options.dom ?? new BrowserSidebarThreadTreeDom(document); + /** Host-persisted relation; `null` parent means none is recorded yet. */ + const hostRelations = new Map(); + /** Native Fork sources never change, so a settled read is kept for the Renderer lifetime. */ + const nativeForks = new Map(); + const nativeFailed = new Set(); + const pending = new Set(); + const nativePending = new Set(); + const nativeQueue: { hostId: string; threadId: HostThreadId }[] = []; + const failed = new Set(); + const retryAttempts = new Map(); + const retryTimers = new Map>(); + const collapsed = new Set(); + let nativeActive = 0; + let disposed = false; + let scanScheduled = false; + + const threadKey = (hostId: string, threadId: string): string => + JSON.stringify([hostId, threadId]); + + const scheduleScan = (): void => { + if (disposed || scanScheduled) return; + scanScheduled = true; + requestAnimationFrame(scan); + }; + + const scheduleRetry = (key: string): void => { + if (disposed || retryTimers.has(key)) return; + const attempt = retryAttempts.get(key) ?? 0; + const delay = RELATION_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) return; + retryAttempts.set(key, attempt + 1); + retryTimers.set( + key, + setTimeout(() => { + retryTimers.delete(key); + if (disposed) return; + failed.delete(key); + const relation = hostRelations.get(key); + if (relation?.parent === null) hostRelations.delete(key); + scheduleScan(); + }, delay), + ); + }; + + const pumpNativeReads = (): void => { + while (!disposed && nativeActive < NATIVE_FORK_READ_CONCURRENCY) { + const next = nativeQueue.shift(); + if (!next) return; + const key = threadKey(next.hostId, next.threadId); + const read = options.getClient(next.hostId)?.readNativeThreadForkSource; + if (!read) { + nativePending.delete(key); + nativeForks.set(key, null); + continue; + } + nativeActive += 1; + void Promise.resolve() + .then(() => read(next.threadId)) + .then( + (parent) => { + if (disposed) return; + nativeForks.set(key, parent); + if (parent !== null) scheduleScan(); + }, + () => { + // A failed native read leaves the row at the top level; refresh retries. + if (!disposed) nativeFailed.add(key); + }, + ) + .finally(() => { + nativeActive -= 1; + nativePending.delete(key); + pumpNativeReads(); + }); + } + }; + + const requestNativeFork = (hostId: string, threadId: HostThreadId): void => { + const key = threadKey(hostId, threadId); + if (nativeForks.has(key) || nativePending.has(key) || nativeFailed.has(key)) return; + nativePending.add(key); + nativeQueue.push({ hostId, threadId }); + pumpNativeReads(); + }; + + const requestRelations = ( + hostId: string, + threadIds: HostThreadId[], + client: RendererModelClient, + ): void => { + const list = client.listThreadRelations; + if (!list) { + for (const threadId of threadIds) { + hostRelations.set(threadKey(hostId, threadId), { owner: "codex", parent: null }); + requestNativeFork(hostId, threadId); + } + return; + } + for (const threadId of threadIds) pending.add(threadKey(hostId, threadId)); + void Promise.resolve() + .then(() => list({ threadIds })) + .then( + ({ threads }) => { + if (disposed) return; + let nested = false; + for (const relation of threads) { + const key = threadKey(hostId, relation.threadId); + hostRelations.set(key, { + owner: relation.owner, + parent: relation.parent?.threadId ?? null, + }); + failed.delete(key); + if (relation.parent) { + nested = true; + clearTimeout(retryTimers.get(key)); + retryTimers.delete(key); + continue; + } + // A Delegation can be recorded just after its child row appears. + scheduleRetry(key); + if (relation.owner === "codex") requestNativeFork(hostId, relation.threadId); + } + if (nested) scheduleScan(); + }, + (error: unknown) => { + if (disposed) return; + for (const threadId of threadIds) { + const key = threadKey(hostId, threadId); + if (error instanceof RendererMethodUnavailableError) { + hostRelations.set(key, { owner: "codex", parent: null }); + requestNativeFork(hostId, threadId); + } else { + failed.add(key); + scheduleRetry(key); + } + } + }, + ) + .finally(() => { + for (const threadId of threadIds) pending.delete(threadKey(hostId, threadId)); + }); + }; + + const parentKeyOf = (hostId: string, key: string): string | null => { + const parent = hostRelations.get(key)?.parent ?? nativeForks.get(key) ?? null; + return parent === null ? null : threadKey(hostId, parent); + }; + + const scan = (): void => { + scanScheduled = false; + if (disposed) return; + const unresolved = new Map>(); + for (const list of dom.lists()) { + const items = list.items(); + const entries = items.map((item): SidebarThreadTreeEntry => { + const thread = item.thread(); + if (!thread) return { key: null, parentKey: null }; + const key = threadKey(thread.hostId, thread.threadId); + if (!hostRelations.has(key) && !pending.has(key) && !failed.has(key)) { + let ids = unresolved.get(thread.hostId); + if (!ids) unresolved.set(thread.hostId, (ids = new Set())); + ids.add(thread.threadId); + } + return { key, parentKey: parentKeyOf(thread.hostId, key) }; + }); + const plans = layoutSidebarThreadTree(entries, collapsed); + items.forEach((item, index) => { + const plan = plans?.[index]; + const key = entries[index]?.key ?? null; + item.apply( + plan + ? { + order: plan.order, + depth: plan.depth, + hidden: plan.hidden, + toggle: + plan.childCount > 0 && key !== null + ? { + collapsed: plan.collapsed, + childCount: plan.childCount, + onToggle: () => { + if (disposed) return; + if (collapsed.has(key)) collapsed.delete(key); + else collapsed.add(key); + scheduleScan(); + }, + } + : null, + } + : null, + ); + }); + } + for (const [hostId, ids] of unresolved) { + const client = options.getClient(hostId); + if (!client) continue; + const threadIds = [...ids]; + for (let index = 0; index < threadIds.length; index += THREAD_RELATION_LIST_MAX_LENGTH) { + requestRelations( + hostId, + threadIds.slice(index, index + THREAD_RELATION_LIST_MAX_LENGTH), + client, + ); + } + } + }; + + const stopObserving = dom.observe(scheduleScan); + scan(); + + return { + refresh() { + if (disposed) return; + failed.clear(); + for (const timer of retryTimers.values()) clearTimeout(timer); + retryTimers.clear(); + retryAttempts.clear(); + // Recorded parents are immutable; only re-ask Threads without one. + for (const [key, relation] of hostRelations) { + if (relation.parent === null) hostRelations.delete(key); + } + nativeFailed.clear(); + scheduleScan(); + }, + dispose() { + if (disposed) return; + disposed = true; + stopObserving(); + dom.clear(); + for (const timer of retryTimers.values()) clearTimeout(timer); + retryTimers.clear(); + retryAttempts.clear(); + hostRelations.clear(); + nativeForks.clear(); + nativeFailed.clear(); + pending.clear(); + nativePending.clear(); + nativeQueue.length = 0; + failed.clear(); + collapsed.clear(); + }, + }; +} diff --git a/packages/renderer-extension/test/renderer-binding-probe-host-catalog.test.ts b/packages/renderer-extension/test/renderer-binding-probe-host-catalog.test.ts index e3d40762f..f36df6dde 100644 --- a/packages/renderer-extension/test/renderer-binding-probe-host-catalog.test.ts +++ b/packages/renderer-extension/test/renderer-binding-probe-host-catalog.test.ts @@ -114,6 +114,10 @@ vi.mock("../src/renderer-sidebar-agent-icons.js", () => ({ }, })); +vi.mock("../src/renderer-sidebar-thread-tree.js", () => ({ + installRendererSidebarThreadTree: () => ({ refresh: vi.fn(), dispose: vi.fn() }), +})); + vi.mock("../src/renderer-settings-lifecycle.js", () => ({ installRendererSettingsLifecycle: ( _window: unknown, diff --git a/packages/renderer-extension/test/renderer-model-client.test.ts b/packages/renderer-extension/test/renderer-model-client.test.ts index 800370db2..d5a3ee2bd 100644 --- a/packages/renderer-extension/test/renderer-model-client.test.ts +++ b/packages/renderer-extension/test/renderer-model-client.test.ts @@ -427,9 +427,11 @@ describe("Renderer fixed Model request client", () => { "listLoadedSessions", "listSessionImportSources", "listThreadOwnership", + "listThreadRelations", "openConsole", "openHarnessWebUi", "readDelegationThread", + "readNativeThreadForkSource", "readUpdateStatus", "refreshCodexAccounts", "runtimeStatus", @@ -869,6 +871,94 @@ describe("Renderer fixed Model request client", () => { ).rejects.toThrow("does not match"); }); + it("lists sidebar Thread relations and falls back to ownership on older Hosts", async () => { + const ids = [hostThreadIdSchema.parse("child"), hostThreadIdSchema.parse("parent")]; + const relations = { + threads: [ + { + threadId: "child", + owner: "external", + parent: { threadId: "parent", relation: "delegation" }, + }, + { threadId: "parent", owner: "codex", parent: null }, + ], + }; + const current = createRendererModelClient([{ sendRequest: vi.fn(async () => relations) }]); + await expect(current?.listThreadRelations?.({ threadIds: ids })).resolves.toEqual(relations); + + const unavailable = Object.assign(new Error("Method not found"), { code: -32601 }); + const older = vi.fn(async (method: string) => { + if (method === "codexhost/thread/relation/list") throw unavailable; + return { + threads: [ + { threadId: "child", owner: "external", harnessId: "pi" }, + { threadId: "parent", owner: "codex" }, + ], + }; + }); + const olderClient = createRendererModelClient([{ sendRequest: older }]); + await expect(olderClient?.listThreadRelations?.({ threadIds: ids })).resolves.toEqual({ + threads: [ + { threadId: "child", owner: "external", parent: null }, + { threadId: "parent", owner: "codex", parent: null }, + ], + }); + + const stock = createRendererModelClient([ + { + sendRequest: vi.fn(async () => { + throw unavailable; + }), + }, + ]); + await expect(stock?.listThreadRelations?.({ threadIds: ids })).resolves.toEqual({ + threads: [ + { threadId: "child", owner: "codex", parent: null }, + { threadId: "parent", owner: "codex", parent: null }, + ], + }); + }); + + it("rejects relation results that do not exactly match requested IDs", async () => { + const client = createRendererModelClient([ + { + sendRequest: vi.fn(async () => ({ + threads: [{ threadId: "other", owner: "codex", parent: null }], + })), + }, + ]); + + await expect( + client?.listThreadRelations?.({ threadIds: [hostThreadIdSchema.parse("thread-1")] }), + ).rejects.toThrow("does not match"); + }); + + it("reads a native Fork source from metadata only and ignores Subagents", async () => { + const threads: Record = { + fork: { id: "fork", forkedFromId: "parent" }, + plain: { id: "plain", forkedFromId: null }, + subagent: { id: "subagent", forkedFromId: "parent", parentThreadId: "parent" }, + spawned: { id: "spawned", forkedFromId: "parent", source: { subAgent: {} } }, + mismatch: { id: "other" }, + }; + const sendRequest = vi.fn(async (_method: string, params: unknown) => ({ + thread: threads[(params as { threadId: string }).threadId], + })); + const client = createRendererModelClient([{ sendRequest }]); + const read = (threadId: string) => + client?.readNativeThreadForkSource?.(hostThreadIdSchema.parse(threadId)); + + await expect(read("fork")).resolves.toBe("parent"); + await expect(read("plain")).resolves.toBeNull(); + await expect(read("subagent")).resolves.toBeNull(); + await expect(read("spawned")).resolves.toBeNull(); + await expect(read("mismatch")).rejects.toThrow("does not match"); + expect(sendRequest).toHaveBeenCalledWith("thread/read", { + threadId: "fork", + includeTurns: false, + }); + }); + it("accepts a null update check when the Host has no update capability", async () => { const client = createRendererModelClient([{ sendRequest: vi.fn(async () => null) }]); if (!client) throw new Error("Synthetic update client was not created"); diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts new file mode 100644 index 000000000..47b6988b3 --- /dev/null +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -0,0 +1,410 @@ +import { + hostThreadIdSchema, + type HostThreadId, + type ThreadRelationListParams, + type ThreadRelationListResult, +} from "@codexhost/shared-contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RendererModelClient } from "../src/renderer-model-client.js"; +import { RendererMethodUnavailableError } from "../src/renderer-request-sender.js"; +import { + installRendererSidebarThreadTree, + layoutSidebarThreadTree, + type SidebarThreadTreeDom, + type SidebarThreadTreeItem, + type SidebarThreadTreeLayout, + type SidebarThreadTreeList, +} from "../src/renderer-sidebar-thread-tree.js"; + +const id = (value: string): HostThreadId => hostThreadIdSchema.parse(value); + +class FakeItem implements SidebarThreadTreeItem { + layout: SidebarThreadTreeLayout | null = null; + applies = 0; + + constructor( + public threadId: string | null, + public hostId = "local", + ) {} + + thread(): { hostId: string; threadId: HostThreadId } | null { + return this.threadId === null ? null : { hostId: this.hostId, threadId: id(this.threadId) }; + } + + apply(layout: SidebarThreadTreeLayout | null): void { + this.layout = layout; + this.applies += 1; + } +} + +class FakeList implements SidebarThreadTreeList { + constructor(public mounted: FakeItem[]) {} + + items(): readonly SidebarThreadTreeItem[] { + return this.mounted; + } +} + +class FakeDom implements SidebarThreadTreeDom { + readonly listeners = new Set<() => void>(); + cleared = false; + + constructor(public mounted: FakeList[]) {} + + lists(): readonly SidebarThreadTreeList[] { + return this.mounted; + } + + observe(onChange: () => void): () => void { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + } + + clear(): void { + this.cleared = true; + for (const list of this.mounted) for (const item of list.mounted) item.apply(null); + } + + change(): void { + for (const listener of this.listeners) listener(); + } +} + +function clientWith(options: { + relations?: Record; + external?: readonly string[]; + forks?: Record; + listThreadRelations?: (input: ThreadRelationListParams) => Promise; + readNativeThreadForkSource?: (threadId: HostThreadId) => Promise; +}): RendererModelClient { + const listThreadRelations = + options.listThreadRelations ?? + (async ({ threadIds }: ThreadRelationListParams): Promise => ({ + threads: threadIds.map((threadId) => { + const relation = options.relations?.[threadId]; + const external = options.external?.includes(threadId) === true || relation !== undefined; + return { + threadId, + owner: external ? ("external" as const) : ("codex" as const), + parent: relation + ? { threadId: id(relation.parent), relation: relation.relation ?? "delegation" } + : null, + }; + }), + })); + const readNativeThreadForkSource = + options.readNativeThreadForkSource ?? + (async (threadId: HostThreadId) => { + const parent = options.forks?.[threadId]; + return parent === undefined ? null : id(parent); + }); + return { + forkThread: vi.fn(), + inspectHarness: vi.fn(), + inspectThread: vi.fn(), + inspectHarnessCommands: vi.fn(), + inspectThreadCommands: vi.fn(), + executeThreadCommand: vi.fn(), + inspectThreadUsage: vi.fn(), + listThreadOwnership: vi.fn(), + listThreadRelations: vi.fn(listThreadRelations), + readNativeThreadForkSource: vi.fn(readNativeThreadForkSource), + selectThreadModel: vi.fn(), + selectThreadThinking: vi.fn(), + selectThreadPermissionMode: vi.fn(), + checkUpdate: vi.fn(), + startUpdate: vi.fn(), + readUpdateStatus: vi.fn(), + listCodexAccounts: vi.fn(), + refreshCodexAccounts: vi.fn(), + }; +} + +async function settle(): Promise { + for (let index = 0; index < 4; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function summary(items: readonly FakeItem[]): unknown[] { + return items.map((item) => + item.layout === null + ? [item.threadId, null] + : [ + item.threadId, + item.layout.order, + item.layout.depth, + item.layout.hidden, + item.layout.toggle + ? { collapsed: item.layout.toggle.collapsed, children: item.layout.toggle.childCount } + : null, + ], + ); +} + +describe("layoutSidebarThreadTree", () => { + it("places children depth-first under their parent in native sibling order", () => { + const plans = layoutSidebarThreadTree([ + { key: "child-b", parentKey: "parent" }, + { key: "other", parentKey: null }, + { key: "parent", parentKey: null }, + { key: "grandchild", parentKey: "child-a" }, + { key: "child-a", parentKey: "parent" }, + ]); + + expect(plans?.map(({ order, depth }) => [order, depth])).toEqual([ + [2, 1], + [0, 0], + [1, 0], + [4, 2], + [3, 1], + ]); + expect(plans?.[2]?.childCount).toBe(2); + }); + + it("returns null when no entry in the list has a parent in that list", () => { + expect( + layoutSidebarThreadTree([ + { key: "a", parentKey: null }, + { key: "b", parentKey: "elsewhere" }, + { key: null, parentKey: null }, + ]), + ).toBeNull(); + }); + + it("breaks cycles and keeps self parents and duplicate keys at the top level", () => { + const plans = layoutSidebarThreadTree([ + { key: "a", parentKey: "b" }, + { key: "b", parentKey: "a" }, + { key: "self", parentKey: "self" }, + { key: "root", parentKey: null }, + { key: "child", parentKey: "root" }, + { key: "child", parentKey: "root" }, + ]); + + expect(plans?.map(({ order, depth }) => [order, depth])).toEqual([ + [0, 0], + [1, 1], + [2, 0], + [3, 0], + [4, 1], + [5, 0], + ]); + }); + + it("hides every descendant of a collapsed parent", () => { + const plans = layoutSidebarThreadTree( + [ + { key: "root", parentKey: null }, + { key: "child", parentKey: "root" }, + { key: "grandchild", parentKey: "child" }, + ], + new Set(["root"]), + ); + + expect(plans?.map(({ hidden, collapsed }) => [hidden, collapsed])).toEqual([ + [false, true], + [true, false], + [true, false], + ]); + }); +}); + +describe("Renderer sidebar Thread tree", () => { + beforeEach(() => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setTimeout(() => callback(performance.now()), 0), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("nests Delegation children and native Forks under parents in the same list", async () => { + const items = [ + new FakeItem("delegated-child"), + new FakeItem("native-fork"), + new FakeItem("parent"), + new FakeItem(null), + ]; + const dom = new FakeDom([new FakeList(items)]); + const client = clientWith({ + relations: { "delegated-child": { parent: "parent" } }, + forks: { "native-fork": "parent" }, + }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + + await settle(); + + expect(client.listThreadRelations).toHaveBeenCalledTimes(1); + expect(client.listThreadRelations).toHaveBeenCalledWith({ + threadIds: ["delegated-child", "native-fork", "parent"], + }); + // Only Codex-owned Threads without a Host relation read native metadata. + expect(client.readNativeThreadForkSource).toHaveBeenCalledTimes(2); + expect(client.readNativeThreadForkSource).not.toHaveBeenCalledWith("delegated-child"); + expect(summary(items)).toEqual([ + ["delegated-child", 1, 1, false, null], + ["native-fork", 2, 1, false, null], + ["parent", 0, 0, false, { collapsed: false, children: 2 }], + [null, 3, 0, false, null], + ]); + control.dispose(); + }); + + it("collapses and expands children through the parent toggle", async () => { + const items = [new FakeItem("parent"), new FakeItem("child")]; + const dom = new FakeDom([new FakeList(items)]); + const client = clientWith({ relations: { child: { parent: "parent" } } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + items[0]?.layout?.toggle?.onToggle(); + await settle(); + expect(summary(items)).toEqual([ + ["parent", 0, 0, false, { collapsed: true, children: 1 }], + ["child", 1, 1, true, null], + ]); + + items[0]?.layout?.toggle?.onToggle(); + await settle(); + expect(items[1]?.layout?.hidden).toBe(false); + control.dispose(); + }); + + it("re-applies the layout after the native list re-renders", async () => { + const list = new FakeList([new FakeItem("parent"), new FakeItem("child")]); + const dom = new FakeDom([list]); + const client = clientWith({ relations: { child: { parent: "parent" } } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + // React replaced both list items; cached relations apply without a new request. + list.mounted = [new FakeItem("child"), new FakeItem("parent")]; + dom.change(); + await settle(); + + expect(summary(list.mounted)).toEqual([ + ["child", 1, 1, false, null], + ["parent", 0, 0, false, { collapsed: false, children: 1 }], + ]); + expect(client.listThreadRelations).toHaveBeenCalledTimes(1); + control.dispose(); + }); + + it("does not nest a child whose parent is in another list", async () => { + const first = new FakeList([new FakeItem("parent")]); + const second = new FakeList([new FakeItem("child")]); + const dom = new FakeDom([first, second]); + const client = clientWith({ relations: { child: { parent: "parent" } } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + expect(first.mounted[0]?.layout).toBeNull(); + expect(second.mounted[0]?.layout).toBeNull(); + control.dispose(); + }); + + it("keys relations by Host so equal Thread ids on different Hosts stay apart", async () => { + const items = [new FakeItem("parent", "local"), new FakeItem("child", "remote")]; + const dom = new FakeDom([new FakeList(items)]); + const clients = new Map([ + ["local", clientWith({})], + ["remote", clientWith({ relations: { child: { parent: "parent" } } })], + ]); + const control = installRendererSidebarThreadTree({ + getClient: (hostId) => clients.get(hostId) ?? null, + dom, + }); + await settle(); + + expect(clients.get("local")?.listThreadRelations).toHaveBeenCalledWith({ + threadIds: ["parent"], + }); + expect(items.map((item) => item.layout)).toEqual([null, null]); + control.dispose(); + }); + + it("falls back to native Fork metadata when the Host lacks the relation method", async () => { + const items = [new FakeItem("parent"), new FakeItem("fork")]; + const dom = new FakeDom([new FakeList(items)]); + const client = clientWith({ + listThreadRelations: async () => { + throw new RendererMethodUnavailableError( + "codexhost/thread/relation/list", + new Error("Method not found"), + ); + }, + forks: { fork: "parent" }, + }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + expect(summary(items)).toEqual([ + ["parent", 0, 0, false, { collapsed: false, children: 1 }], + ["fork", 1, 1, false, null], + ]); + control.dispose(); + }); + + it("retries a Delegation recorded after its child row appeared", async () => { + vi.useFakeTimers(); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0), + ); + const items = [new FakeItem("parent"), new FakeItem("child")]; + const dom = new FakeDom([new FakeList(items)]); + let recorded = false; + const client = clientWith({ + listThreadRelations: async ({ threadIds }) => ({ + threads: threadIds.map((threadId) => ({ + threadId, + owner: "external" as const, + parent: + recorded && threadId === "child" + ? { threadId: id("parent"), relation: "delegation" as const } + : null, + })), + }), + }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await vi.advanceTimersByTimeAsync(10); + expect(items[1]?.layout).toBeNull(); + + recorded = true; + await vi.advanceTimersByTimeAsync(1_100); + expect(items[1]?.layout?.depth).toBe(1); + control.dispose(); + }); + + it("restores every list item on dispose and ignores late responses", async () => { + let resolve: ((result: ThreadRelationListResult) => void) | undefined; + const items = [new FakeItem("parent"), new FakeItem("child")]; + const dom = new FakeDom([new FakeList(items)]); + const client = clientWith({ + listThreadRelations: () => new Promise((done) => (resolve = done)), + }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + control.dispose(); + resolve?.({ + threads: [ + { threadId: id("parent"), owner: "external", parent: null }, + { + threadId: id("child"), + owner: "external", + parent: { threadId: id("parent"), relation: "delegation" }, + }, + ], + }); + await settle(); + + expect(dom.cleared).toBe(true); + expect(items.map((item) => item.layout)).toEqual([null, null]); + expect(dom.listeners.size).toBe(0); + }); +}); diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 5f933e248..6f7a1810c 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -8,6 +8,7 @@ export * from "./model-price-overrides.js"; export * from "./usage-statistics.js"; export * from "./thread-prewarm.js"; export * from "./thread-questions.js"; +export * from "./thread-relations.js"; export { CONSOLE_HOST_METHODS, CONSOLE_OPEN_METHOD, diff --git a/packages/shared-contracts/src/thread-relations.ts b/packages/shared-contracts/src/thread-relations.ts new file mode 100644 index 000000000..b5d7f815a --- /dev/null +++ b/packages/shared-contracts/src/thread-relations.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { hostThreadIdSchema } from "./ids.js"; + +/** Fixed read-only Host method that reports persisted sidebar parent relations. */ +export const THREAD_RELATION_LIST_METHOD = "codexhost/thread/relation/list"; +export const THREAD_RELATION_LIST_MAX_LENGTH = 100; + +function uniqueThreadIds( + ids: readonly string[], + context: z.RefinementCtx, + message: string, + path: (index: number) => (string | number)[], +): void { + const seen = new Set(); + for (const [index, id] of ids.entries()) { + if (seen.has(id)) context.addIssue({ code: "custom", message, path: path(index) }); + seen.add(id); + } +} + +export const threadRelationListParamsSchema = z + .object({ + threadIds: z.array(hostThreadIdSchema).min(1).max(THREAD_RELATION_LIST_MAX_LENGTH), + }) + .strict() + .superRefine(({ threadIds }, context) => + uniqueThreadIds(threadIds, context, "Thread relation-list IDs must be unique", (index) => [ + "threadIds", + index, + ]), + ); + +export type ThreadRelationListParams = z.infer; + +/** + * `delegation` comes from a CodexHost Delegation record; `fork` from a + * persisted Fork source. Codex Subagent relations are not reported because + * Desktop already renders them natively. + */ +export const threadRelationKindSchema = z.enum(["delegation", "fork"]); + +export type ThreadRelationKind = z.infer; + +export const threadRelationParentSchema = z + .object({ + threadId: hostThreadIdSchema, + relation: threadRelationKindSchema, + }) + .strict(); + +export type ThreadRelationParent = z.infer; + +export const threadRelationSchema = z + .object({ + threadId: hostThreadIdSchema, + /** + * `codex` means this Host has no external record for the Thread, so a + * native Codex Fork source can only be read from native `thread/read`. + */ + owner: z.enum(["codex", "external"]), + parent: threadRelationParentSchema.nullable(), + }) + .strict() + .refine((value) => value.parent?.threadId !== value.threadId, { + message: "A Thread cannot be its own parent", + path: ["parent", "threadId"], + }); + +export type ThreadRelation = z.infer; + +export const threadRelationListResultSchema = z + .object({ + threads: z.array(threadRelationSchema).min(1).max(THREAD_RELATION_LIST_MAX_LENGTH), + }) + .strict() + .superRefine(({ threads }, context) => + uniqueThreadIds( + threads.map(({ threadId }) => threadId), + context, + "Thread relation-list results must be unique", + (index) => ["threads", index, "threadId"], + ), + ); + +export type ThreadRelationListResult = z.infer; From b528dffd7bf3cc6efbd5869434c6fed2c303915a Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Tue, 3 Nov 2026 10:28:59 +0800 Subject: [PATCH 2/7] fix(renderer): nest forks of pinned threads across sidebar lists Codex Desktop files every thread in exactly one sidebar list and a new fork does not inherit the Pinned section, so the fork of a pinned thread is rendered in Recents or a project list while its parent stays in Pinned. The tree only nested entries within one list, which left those forks at the top level. Plan all sidebar lists together. A parent in the same list still wins; otherwise a child and its subtree are shown in the list of their root ancestor. Desktop owns the native list items, so the native child stays in place, hidden, and a CodexHost-owned mirror is appended to the parent's list and positioned with CSS order and indentation. The mirror is a sanitized clone without row identity, keyboard-cycle or drop-target attributes; it is re-cloned when the source item changes, and click, double-click, auxiliary click, context menu, Enter and Space are replayed on the matching node of the native item. Only threads shown once across all lists move, cross-list cycles stay in place, and mirrors are removed when the parent leaves the sidebar or on dispose. Signed-off-by: Vincent <1729065730@qq.com> --- docs/architecture/sidebar-thread-tree.md | 7 +- .../src/renderer-sidebar-thread-tree.ts | 532 +++++++++++++++--- .../test/renderer-sidebar-thread-tree.test.ts | 198 ++++++- 3 files changed, 640 insertions(+), 97 deletions(-) diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md index 79fc86a2f..5d27b4337 100644 --- a/docs/architecture/sidebar-thread-tree.md +++ b/docs/architecture/sidebar-thread-tree.md @@ -23,6 +23,8 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T - 用 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]` 最前面,阻止事件冒泡到原生行,避免触发导航。折叠状态只保存在当前 Renderer 内存中。 - 关系结果按 Host 批量请求,单批最多 100 个。委派记录可能晚于子行出现,没有父 Thread 的结果在 1 s、3 s、10 s 后各重试一次;读取失败的行保持顶层。native Fork 读取并发上限为 4,结果在 Renderer 生命周期内缓存。 @@ -30,11 +32,12 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T ## 已知边界 -- 父子必须在同一个原生列表中才会嵌套。父 Thread 在其他项目分组、置顶列表或尚未加载到当前页时,子 Thread 留在原位置。 +- 父 Thread 尚未渲染(未加载到当前页,或所在分组已折叠)时,子 Thread 留在原位置;父 Thread 再次出现后自动嵌套。 +- 镜像不承接拖放、悬停卡片和只响应 pointerdown 的交互。 - 视觉顺序由 CSS `order` 决定;依赖 DOM 顺序的键盘循环仍按原生顺序。 - 层级缩进最多 6 级,更深的层级按第 6 级显示。 - 新增 Host 方法需要重启 CodexHost 后生效;只更新 Renderer 时只有 native Fork 嵌套。 ## 验证 -`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、跨列表、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。 +`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、置顶父 Thread 的跨列表 Fork(含后出现的 Fork、折叠、父 Thread 消失和释放镜像)、跨列表的子树、同列表优先、重复 Thread 和跨列表环、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。 diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts index 1dc95d03d..09d7b260e 100644 --- a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -8,12 +8,15 @@ import type { RendererModelClient } from "./renderer-model-client.js"; import { RendererMethodUnavailableError } from "./renderer-request-sender.js"; import { SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + SIDEBAR_THREAD_ID_ATTRIBUTE, + SIDEBAR_THREAD_ROW_ATTRIBUTE, SIDEBAR_THREAD_ROW_SELECTOR, threadIdFromSidebarRowElement, } from "./renderer-sidebar-agent-icons.js"; export const SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE = "data-codexhost-sidebar-thread-tree"; export const SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE = "data-codexhost-sidebar-thread-toggle"; +export const SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE = "data-codexhost-sidebar-thread-mirror"; export const SIDEBAR_THREAD_TREE_INDENT_PX = 14; const SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH = 6; const RELATION_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; @@ -102,15 +105,135 @@ export function layoutSidebarThreadTree( return plans; } +export interface SidebarThreadForestMirrorPlan { + /** Position of the mirrored entry in its own native list. */ + list: number; + index: number; + plan: SidebarThreadTreeLayoutPlan; +} + +export interface SidebarThreadForestListPlan { + /** + * One plan per native entry, or null when the list keeps its native layout. + * A `mirrored` entry is hidden because it is shown under its parent in another list. + */ + items: (SidebarThreadTreeLayoutPlan & { mirrored: boolean })[] | null; + /** Entries of other lists shown in this list, under a parent that lives here. */ + mirrors: SidebarThreadForestMirrorPlan[]; +} + +/** + * Lay out all native lists together. Desktop files every Thread in exactly one + * list, and a new Fork is not pinned, so the Fork of a pinned Thread lands in + * Recents while its parent stays in Pinned. Such a child, with its own + * subtree, is mirrored into the list that shows its root ancestor and hidden + * where Desktop placed it; inside each list `layoutSidebarThreadTree` applies. + * A parent in the same list wins over one elsewhere. Only a key shown once + * across all lists can move, and a cross-list cycle keeps its entries in place. + */ +export function planSidebarThreadForest( + lists: readonly (readonly SidebarThreadTreeEntry[])[], + collapsed: ReadonlySet = new Set(), +): SidebarThreadForestListPlan[] { + const counts = new Map(); + const first = new Map(); + const indexByKey = lists.map((entries, list) => { + const indexes = new Map(); + entries.forEach(({ key }, index) => { + if (key === null) return; + counts.set(key, (counts.get(key) ?? 0) + 1); + if (!first.has(key)) first.set(key, { list, index }); + if (!indexes.has(key)) indexes.set(key, index); + }); + return indexes; + }); + const parentOf = (list: number, index: number): { list: number; index: number } | null => { + const entry = lists[list]?.[index]; + if (!entry || entry.key === null || entry.parentKey === null) return null; + if (entry.parentKey === entry.key || indexByKey[list]?.get(entry.key) !== index) return null; + const sameList = indexByKey[list]?.get(entry.parentKey); + if (sameList !== undefined) return { list, index: sameList }; + if (counts.get(entry.key) !== 1) return null; + return first.get(entry.parentKey) ?? null; + }; + const displayList = (list: number, index: number): number => { + const seen = new Set(); + let at = { list, index }; + for (;;) { + seen.add(`${at.list}:${at.index}`); + const parent = parentOf(at.list, at.index); + if (!parent) return at.list; + if (seen.has(`${parent.list}:${parent.index}`)) return list; + at = parent; + } + }; + const display = lists.map((entries, list) => entries.map((_, index) => displayList(list, index))); + + return lists.map((entries, list): SidebarThreadForestListPlan => { + const shown: { list: number; index: number }[] = []; + entries.forEach((_, index) => { + if (display[list]?.[index] === list) shown.push({ list, index }); + }); + const kept = shown.length; + lists.forEach((other, otherList) => { + if (otherList === list) return; + other.forEach((_, index) => { + if (display[otherList]?.[index] === list) shown.push({ list: otherList, index }); + }); + }); + const shownEntries = shown.map( + (source) => lists[source.list]?.[source.index] ?? { key: null, parentKey: null }, + ); + const plans = + layoutSidebarThreadTree(shownEntries, collapsed) ?? + (kept < entries.length || shown.length > kept + ? shownEntries.map((_, order) => ({ + order, + depth: 0, + hidden: false, + childCount: 0, + collapsed: false, + })) + : null); + if (!plans) return { items: null, mirrors: [] }; + const items = entries.map(() => ({ + order: 0, + depth: 0, + hidden: true, + childCount: 0, + collapsed: false, + mirrored: true, + })); + const mirrors: SidebarThreadForestMirrorPlan[] = []; + shown.forEach((source, position) => { + const plan = plans[position]; + if (!plan) return; + if (source.list === list) items[source.index] = { ...plan, mirrored: false }; + else mirrors.push({ list: source.list, index: source.index, plan }); + }); + return { items, mirrors }; + }); +} + export interface SidebarThreadTreeItem { /** The Host Thread shown by this list item, or null for any other entry. */ thread(): { hostId: string; threadId: HostThreadId } | null; apply(layout: SidebarThreadTreeLayout | null): void; } +export interface SidebarThreadTreeMirror { + /** Thread key of the mirrored entry; one mirror per key and list. */ + key: string; + /** The native entry, in another list, that the mirror shows and forwards input to. */ + source: SidebarThreadTreeItem; + layout: SidebarThreadTreeLayout; +} + export interface SidebarThreadTreeList { - /** Every direct native list entry, in DOM order. */ + /** Every direct native list entry, in DOM order, without mirrors. */ items(): readonly SidebarThreadTreeItem[]; + /** Show exactly these mirrors in this list; mirrors not listed are removed. */ + mirror(mirrors: readonly SidebarThreadTreeMirror[]): void; } export interface SidebarThreadTreeDom { @@ -193,8 +316,56 @@ function setStyle(element: HTMLElement, property: keyof SavedItemStyle, value: s if (element.style[property] !== value) element.style[property] = value; } +function applyItemLayout( + element: HTMLElement, + layout: SidebarThreadTreeLayout, + saved: SavedItemStyle | undefined, +): void { + setStyle(element, "order", String(layout.order)); + const indent = Math.min(layout.depth, SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH); + setStyle( + element, + "marginInlineStart", + indent > 0 ? `${indent * SIDEBAR_THREAD_TREE_INDENT_PX}px` : (saved?.marginInlineStart ?? ""), + ); + setStyle(element, "display", layout.hidden ? "none" : (saved?.display ?? "")); +} + +function applyToggle( + element: HTMLElement, + titleTrigger: HTMLElement | null, + toggle: SidebarThreadTreeToggle | null, +): void { + const existing = [ + ...element.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`), + ]; + if (!toggle || !titleTrigger) { + for (const node of existing) node.remove(); + return; + } + let node = existing.find((candidate) => candidate.parentElement === titleTrigger); + for (const stale of existing) if (stale !== node) stale.remove(); + if (!node) { + node = createToggle(element.ownerDocument); + titleTrigger.prepend(node); + } + toggleHandlers.set(node, toggle.onToggle); + const expanded = toggle.collapsed ? "false" : "true"; + if (node.getAttribute("aria-expanded") !== expanded) node.setAttribute("aria-expanded", expanded); + const label = `${toggle.collapsed ? "Expand" : "Collapse"} ${toggle.childCount} child ${ + toggle.childCount === 1 ? "thread" : "threads" + }`; + if (node.getAttribute("aria-label") !== label) { + node.setAttribute("aria-label", label); + node.title = label; + } + const svg = node.querySelector("svg"); + const transform = toggle.collapsed ? "" : "rotate(90deg)"; + if (svg && svg.style.transform !== transform) svg.style.transform = transform; +} + class BrowserSidebarThreadTreeItem implements SidebarThreadTreeItem { - constructor(private readonly element: HTMLElement) {} + constructor(readonly element: HTMLElement) {} #row(): HTMLElement | null { const rows = this.element.querySelectorAll(SIDEBAR_THREAD_ROW_SELECTOR); @@ -221,49 +392,14 @@ class BrowserSidebarThreadTreeItem implements SidebarThreadTreeItem { display: element.style.display, }); } - const saved = savedItemStyles.get(element); if (!element.hasAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE)) element.setAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE, ""); - setStyle(element, "order", String(layout.order)); - const indent = Math.min(layout.depth, SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH); - setStyle( + applyItemLayout(element, layout, savedItemStyles.get(element)); + applyToggle( element, - "marginInlineStart", - indent > 0 ? `${indent * SIDEBAR_THREAD_TREE_INDENT_PX}px` : (saved?.marginInlineStart ?? ""), + this.#row()?.querySelector("[data-thread-title-trigger]") ?? null, + layout.toggle, ); - setStyle(element, "display", layout.hidden ? "none" : (saved?.display ?? "")); - this.#applyToggle(layout.toggle); - } - - #applyToggle(toggle: SidebarThreadTreeToggle | null): void { - const existing = [ - ...this.element.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`), - ]; - const titleTrigger = this.#row()?.querySelector("[data-thread-title-trigger]"); - if (!toggle || !titleTrigger) { - for (const node of existing) node.remove(); - return; - } - let node = existing.find((candidate) => candidate.parentElement === titleTrigger); - for (const stale of existing) if (stale !== node) stale.remove(); - if (!node) { - node = createToggle(this.element.ownerDocument); - titleTrigger.prepend(node); - } - toggleHandlers.set(node, toggle.onToggle); - const expanded = toggle.collapsed ? "false" : "true"; - if (node.getAttribute("aria-expanded") !== expanded) - node.setAttribute("aria-expanded", expanded); - const label = `${toggle.collapsed ? "Expand" : "Collapse"} ${toggle.childCount} child ${ - toggle.childCount === 1 ? "thread" : "threads" - }`; - if (node.getAttribute("aria-label") !== label) { - node.setAttribute("aria-label", label); - node.title = label; - } - const svg = node.querySelector("svg"); - const transform = toggle.collapsed ? "" : "rotate(90deg)"; - if (svg && svg.style.transform !== transform) svg.style.transform = transform; } } @@ -280,20 +416,218 @@ function clearSidebarThreadTreeItem(element: HTMLElement): void { savedItemStyles.delete(element); } +/** + * Attributes a mirror must not carry: they identify the native row to Desktop + * (selection, keyboard cycling, drag and drop) and to CodexHost bindings. + */ +const MIRROR_STRIPPED_ATTRIBUTES = [ + "id", + "draggable", + SIDEBAR_THREAD_ROW_ATTRIBUTE, + SIDEBAR_THREAD_ID_ATTRIBUTE, + SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE, + "data-sidebar-cycle-item", + "data-pinned-content-tab-drop-key", + "data-codex-tab-conversation-drop-target", +] as const; +const MIRROR_FORWARDED_EVENTS = [ + "click", + "dblclick", + "auxclick", + "contextmenu", + "keydown", +] as const; + +interface MirrorState { + source: HTMLElement; + /** Source markup the mirror was cloned from; a change re-clones it. */ + signature: string; +} + +const mirrorStates = new WeakMap(); + +/** Element children that also exist in the source; toggles are mirror-only. */ +function mirroredChildren(element: Element): Element[] { + return [...element.children].filter( + (child) => !child.hasAttribute(SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE), + ); +} + +/** The source node at the same position as `target` inside the mirror. */ +function mirrorCounterpart( + mirror: HTMLElement, + source: HTMLElement, + target: EventTarget | null, +): Element | null { + let node: Element | null = + target instanceof Element ? target : target instanceof Node ? target.parentElement : null; + const path: number[] = []; + while (node && node !== mirror) { + const parent: Element | null = node.parentElement; + if (!parent) return null; + path.push(mirroredChildren(parent).indexOf(node)); + node = parent; + } + if (node !== mirror) return null; + let match: Element | undefined = source; + for (const index of path.reverse()) { + match = index < 0 ? undefined : mirroredChildren(match).at(index); + if (!match) return null; + } + return match; +} + +/** + * Replay activation input on the hidden native entry so navigation, the + * context menu, and row actions stay Desktop's own. Pointer-only gestures + * such as drag and drop and hover cards are not forwarded. + */ +function forwardMirrorEvent(event: Event): void { + const mirror = event.currentTarget; + if (!(mirror instanceof HTMLElement)) return; + if (event instanceof KeyboardEvent && event.key !== "Enter" && event.key !== " ") return; + const source = mirrorStates.get(mirror)?.source; + if (!source?.isConnected) return; + const target = + mirrorCounterpart(mirror, source, event.target) ?? + source.querySelector(SIDEBAR_THREAD_ROW_SELECTOR) ?? + source; + const modifiers = { + bubbles: true, + cancelable: true, + composed: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }; + let forwarded: Event | null = null; + if (event instanceof KeyboardEvent) { + forwarded = new KeyboardEvent(event.type, { + ...modifiers, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + key: event.key, + code: event.code, + repeat: event.repeat, + }); + } else if (event instanceof MouseEvent) { + forwarded = new MouseEvent(event.type, { + ...modifiers, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + detail: event.detail, + screenX: event.screenX, + screenY: event.screenY, + clientX: event.clientX, + clientY: event.clientY, + button: event.button, + buttons: event.buttons, + }); + } + if (!forwarded) return; + event.preventDefault(); + event.stopPropagation(); + target.dispatchEvent(forwarded); +} + +function createMirror(source: HTMLElement, key: string, signature: string): HTMLElement { + const mirror = source.cloneNode(true); + if (!(mirror instanceof HTMLElement)) + throw new Error("Sidebar list item clone is not an element"); + for (const node of mirror.querySelectorAll(`[${SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE}]`)) { + node.remove(); + } + for (const element of [mirror, ...mirror.querySelectorAll("*")]) { + for (const name of MIRROR_STRIPPED_ATTRIBUTES) element.removeAttribute(name); + } + mirror.setAttribute(SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE, key); + for (const type of MIRROR_FORWARDED_EVENTS) mirror.addEventListener(type, forwardMirrorEvent); + mirrorStates.set(mirror, { source, signature }); + return mirror; +} + function sidebarListItem(row: Element): HTMLElement | null { const item = row.closest('[role="listitem"]'); return item?.parentElement?.getAttribute("role") === "list" ? item : null; } +class BrowserSidebarThreadTreeList implements SidebarThreadTreeList { + constructor( + private readonly element: HTMLElement, + private readonly itemFor: (element: HTMLElement) => BrowserSidebarThreadTreeItem, + ) {} + + items(): readonly SidebarThreadTreeItem[] { + return [...this.element.children] + .filter( + (child): child is HTMLElement => + child instanceof HTMLElement && !child.hasAttribute(SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE), + ) + .map((child) => this.itemFor(child)); + } + + mirror(mirrors: readonly SidebarThreadTreeMirror[]): void { + const existing = new Map(); + for (const child of [...this.element.children]) { + const key = child.getAttribute(SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE); + if (key === null || !(child instanceof HTMLElement)) continue; + if (existing.has(key)) child.remove(); + else existing.set(key, child); + } + for (const { key, source, layout } of mirrors) { + if (!(source instanceof BrowserSidebarThreadTreeItem)) continue; + let mirror = existing.get(key); + existing.delete(key); + const signature = source.element.innerHTML; + const state = mirror && mirrorStates.get(mirror); + if (!mirror || state?.source !== source.element || state.signature !== signature) { + const next = createMirror(source.element, key, signature); + if (mirror) mirror.replaceWith(next); + else this.element.append(next); + mirror = next; + } + // The source is hidden by layout; show the mirror with its original display. + applyItemLayout(mirror, layout, savedItemStyles.get(source.element)); + applyToggle( + mirror, + mirror.querySelector("[data-thread-title-trigger]"), + layout.toggle, + ); + } + for (const stale of existing.values()) stale.remove(); + } +} + +/** Row attributes whose change alters what a mirror must show. */ +const MIRRORED_ROW_ATTRIBUTES = new Set([ + "data-app-action-sidebar-thread-active", + "data-app-action-sidebar-thread-selected", + "data-app-action-sidebar-thread-title", +]); + class BrowserSidebarThreadTreeDom implements SidebarThreadTreeDom { readonly #items = new WeakMap(); + readonly #lists = new WeakMap(); constructor(private readonly root: ParentNode & Node) {} - #item(element: HTMLElement): BrowserSidebarThreadTreeItem { + #item = (element: HTMLElement): BrowserSidebarThreadTreeItem => { let item = this.#items.get(element); if (!item) this.#items.set(element, (item = new BrowserSidebarThreadTreeItem(element))); return item; + }; + + #list(element: HTMLElement): BrowserSidebarThreadTreeList { + let list = this.#lists.get(element); + if (!list) + this.#lists.set(element, (list = new BrowserSidebarThreadTreeList(element, this.#item))); + return list; } lists(): readonly SidebarThreadTreeList[] { @@ -302,38 +636,45 @@ class BrowserSidebarThreadTreeDom implements SidebarThreadTreeDom { const list = sidebarListItem(row)?.parentElement; if (list) lists.add(list); } - // Lists that lost their last thread row must still drop old layout. + // Lists that lost their last thread row must still drop old layout and mirrors. for (const item of this.root.querySelectorAll( - `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`, + `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}], [${SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE}]`, )) { if (item.parentElement) lists.add(item.parentElement); else clearSidebarThreadTreeItem(item); } - return [...lists].map((list) => ({ - items: () => - [...list.children] - .filter((child): child is HTMLElement => child instanceof HTMLElement) - .map((child) => this.#item(child)), - })); + return [...lists].map((list) => this.#list(list)); } observe(onChange: () => void): () => void { + const marked = `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}], [${SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE}]`; const affectsSidebar = (node: Node | null): boolean => { if (!(node instanceof Element)) return false; if (node.closest(SIDEBAR_THREAD_ROW_SELECTOR)) return true; - if (node.closest(`[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`)) return true; + if (node.closest(marked)) return true; if ( node.matches(SIDEBAR_THREAD_ROW_SELECTOR) || node.querySelector(SIDEBAR_THREAD_ROW_SELECTOR) ) return true; - return ( - node.getAttribute("role") === "list" && - node.querySelector(`[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`) !== null - ); + return node.getAttribute("role") === "list" && node.querySelector(marked) !== null; }; const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { + if ( + mutation.type === "attributes" && + MIRRORED_ROW_ATTRIBUTES.has(mutation.attributeName ?? "") + ) { + // Only rows carry these; a cheap ancestor check keeps busy pages fast. + if ( + mutation.target instanceof Element && + mutation.target.closest(SIDEBAR_THREAD_ROW_SELECTOR) + ) { + onChange(); + return; + } + continue; + } if ( affectsSidebar(mutation.target) || [...mutation.addedNodes].some(affectsSidebar) || @@ -347,9 +688,10 @@ class BrowserSidebarThreadTreeDom implements SidebarThreadTreeDom { observer.observe(this.root, { attributes: true, attributeFilter: [ - "data-app-action-sidebar-thread-row", - "data-app-action-sidebar-thread-id", + SIDEBAR_THREAD_ROW_ATTRIBUTE, + SIDEBAR_THREAD_ID_ATTRIBUTE, SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + ...MIRRORED_ROW_ATTRIBUTES, "role", "style", ], @@ -360,6 +702,9 @@ class BrowserSidebarThreadTreeDom implements SidebarThreadTreeDom { } clear(): void { + for (const mirror of this.root.querySelectorAll(`[${SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE}]`)) { + mirror.remove(); + } for (const item of this.root.querySelectorAll( `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}]`, )) { @@ -533,9 +878,10 @@ export function installRendererSidebarThreadTree(options: { scanScheduled = false; if (disposed) return; const unresolved = new Map>(); - for (const list of dom.lists()) { - const items = list.items(); - const entries = items.map((item): SidebarThreadTreeEntry => { + const lists = dom.lists(); + const listItems = lists.map((list) => list.items()); + const entries = listItems.map((items) => + items.map((item): SidebarThreadTreeEntry => { const thread = item.thread(); if (!thread) return { key: null, parentKey: null }; const key = threadKey(thread.hostId, thread.threadId); @@ -545,35 +891,47 @@ export function installRendererSidebarThreadTree(options: { ids.add(thread.threadId); } return { key, parentKey: parentKeyOf(thread.hostId, key) }; - }); - const plans = layoutSidebarThreadTree(entries, collapsed); + }), + ); + const layoutOf = ( + plan: SidebarThreadTreeLayoutPlan, + key: string | null, + ): SidebarThreadTreeLayout => ({ + order: plan.order, + depth: plan.depth, + hidden: plan.hidden, + toggle: + plan.childCount > 0 && key !== null + ? { + collapsed: plan.collapsed, + childCount: plan.childCount, + onToggle: () => { + if (disposed) return; + if (collapsed.has(key)) collapsed.delete(key); + else collapsed.add(key); + scheduleScan(); + }, + } + : null, + }); + const plans = planSidebarThreadForest(entries, collapsed); + // Lay out native entries first so every mirror clones its settled source. + listItems.forEach((items, listIndex) => { + const listPlan = plans[listIndex]?.items ?? null; items.forEach((item, index) => { - const plan = plans?.[index]; - const key = entries[index]?.key ?? null; - item.apply( - plan - ? { - order: plan.order, - depth: plan.depth, - hidden: plan.hidden, - toggle: - plan.childCount > 0 && key !== null - ? { - collapsed: plan.collapsed, - childCount: plan.childCount, - onToggle: () => { - if (disposed) return; - if (collapsed.has(key)) collapsed.delete(key); - else collapsed.add(key); - scheduleScan(); - }, - } - : null, - } - : null, - ); + const plan = listPlan?.[index]; + item.apply(plan ? layoutOf(plan, entries[listIndex]?.[index]?.key ?? null) : null); }); - } + }); + lists.forEach((list, listIndex) => { + list.mirror( + (plans[listIndex]?.mirrors ?? []).flatMap(({ list: from, index, plan }) => { + const source = listItems[from]?.[index]; + const key = entries[from]?.[index]?.key; + return source && key ? [{ key, source, layout: layoutOf(plan, key) }] : []; + }), + ); + }); for (const [hostId, ids] of unresolved) { const client = options.getClient(hostId); if (!client) continue; diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts index 47b6988b3..1c47ab655 100644 --- a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -11,10 +11,12 @@ import { RendererMethodUnavailableError } from "../src/renderer-request-sender.j import { installRendererSidebarThreadTree, layoutSidebarThreadTree, + planSidebarThreadForest, type SidebarThreadTreeDom, type SidebarThreadTreeItem, type SidebarThreadTreeLayout, type SidebarThreadTreeList, + type SidebarThreadTreeMirror, } from "../src/renderer-sidebar-thread-tree.js"; const id = (value: string): HostThreadId => hostThreadIdSchema.parse(value); @@ -39,11 +41,32 @@ class FakeItem implements SidebarThreadTreeItem { } class FakeList implements SidebarThreadTreeList { + mirrors: readonly SidebarThreadTreeMirror[] = []; + constructor(public mounted: FakeItem[]) {} items(): readonly SidebarThreadTreeItem[] { return this.mounted; } + + mirror(mirrors: readonly SidebarThreadTreeMirror[]): void { + this.mirrors = mirrors; + } +} + +const forkKey = JSON.stringify(["local", "fork"]); + +function mirrorSummary(list: FakeList): unknown[] { + return list.mirrors.map(({ key, source, layout }) => [ + key, + (source as FakeItem).threadId, + layout.order, + layout.depth, + layout.hidden, + layout.toggle + ? { collapsed: layout.toggle.collapsed, children: layout.toggle.childCount } + : null, + ]); } class FakeDom implements SidebarThreadTreeDom { @@ -63,7 +86,10 @@ class FakeDom implements SidebarThreadTreeDom { clear(): void { this.cleared = true; - for (const list of this.mounted) for (const item of list.mounted) item.apply(null); + for (const list of this.mounted) { + list.mirror([]); + for (const item of list.mounted) item.apply(null); + } } change(): void { @@ -295,17 +321,99 @@ describe("Renderer sidebar Thread tree", () => { control.dispose(); }); - it("does not nest a child whose parent is in another list", async () => { - const first = new FakeList([new FakeItem("parent")]); - const second = new FakeList([new FakeItem("child")]); - const dom = new FakeDom([first, second]); - const client = clientWith({ relations: { child: { parent: "parent" } } }); + it("mirrors the Fork of a pinned Thread under its parent and hides it in Recents", async () => { + const pinned = new FakeList([new FakeItem("pinned-a"), new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("recent-a"), new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ forks: { fork: "parent" } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + expect(summary(pinned.mounted)).toEqual([ + ["pinned-a", 0, 0, false, null], + ["parent", 1, 0, false, { collapsed: false, children: 1 }], + ]); + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 2, 1, false, null]]); + expect(summary(recents.mounted)).toEqual([ + ["recent-a", 0, 0, false, null], + ["fork", 0, 0, true, null], + ]); + expect(recents.mirrors).toEqual([]); + control.dispose(); + }); + + it("nests a Fork that appears after its pinned parent was already laid out", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("recent-a")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ relations: { fork: { parent: "parent", relation: "fork" } } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + expect(pinned.mirrors).toEqual([]); + + recents.mounted.unshift(new FakeItem("fork")); + dom.change(); + await settle(); + + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 1, 1, false, null]]); + expect(summary(recents.mounted)).toEqual([ + ["fork", 0, 0, true, null], + ["recent-a", 0, 0, false, null], + ]); + control.dispose(); + }); + + it("collapses a mirrored child with its pinned parent", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ forks: { fork: "parent" } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + pinned.mounted[0]?.layout?.toggle?.onToggle(); + await settle(); + + expect(summary(pinned.mounted)).toEqual([ + ["parent", 0, 0, false, { collapsed: true, children: 1 }], + ]); + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 1, 1, true, null]]); + expect(summary(recents.mounted)).toEqual([["fork", 0, 0, true, null]]); + control.dispose(); + }); + + it("returns a mirrored child to its own list when the parent leaves the sidebar", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ forks: { fork: "parent" } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + expect(pinned.mirrors).toHaveLength(1); + + pinned.mounted = []; + dom.change(); + await settle(); + + expect(pinned.mirrors).toEqual([]); + expect(recents.mounted[0]?.layout).toBeNull(); + control.dispose(); + }); + + it("removes mirrors on dispose", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ relations: { fork: { parent: "parent", relation: "fork" } } }); const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); await settle(); + expect(pinned.mirrors).toHaveLength(1); - expect(first.mounted[0]?.layout).toBeNull(); - expect(second.mounted[0]?.layout).toBeNull(); control.dispose(); + + expect(dom.cleared).toBe(true); + expect(pinned.mirrors).toEqual([]); + expect(recents.mounted[0]?.layout).toBeNull(); }); it("keys relations by Host so equal Thread ids on different Hosts stay apart", async () => { @@ -408,3 +516,77 @@ describe("Renderer sidebar Thread tree", () => { expect(dom.listeners.size).toBe(0); }); }); + +describe("planSidebarThreadForest", () => { + const entry = (key: string, parentKey: string | null = null) => ({ key, parentKey }); + + it("keeps lists native when nothing nests", () => { + expect(planSidebarThreadForest([[entry("a")], [entry("b")]])).toEqual([ + { items: null, mirrors: [] }, + { items: null, mirrors: [] }, + ]); + }); + + it("moves a child and its subtree under a parent in another list", () => { + const [pinned, recents] = planSidebarThreadForest([ + [entry("parent"), entry("other")], + [entry("grandchild", "child"), entry("recent"), entry("child", "parent")], + ]); + + expect( + pinned?.items?.map(({ order, depth, hidden, mirrored }) => [order, depth, hidden, mirrored]), + ).toEqual([ + [0, 0, false, false], + [3, 0, false, false], + ]); + expect( + pinned?.mirrors.map(({ list, index, plan }) => [list, index, plan.order, plan.depth]), + ).toEqual([ + [1, 0, 2, 2], + [1, 2, 1, 1], + ]); + expect(recents?.items?.map(({ order, hidden, mirrored }) => [order, hidden, mirrored])).toEqual( + [ + [0, true, true], + [0, false, false], + [0, true, true], + ], + ); + expect(recents?.mirrors).toEqual([]); + }); + + it("prefers a parent in the same list", () => { + const [first, second] = planSidebarThreadForest([ + [entry("parent")], + [entry("parent"), entry("child", "parent")], + ]); + + expect(first).toEqual({ items: null, mirrors: [] }); + expect(second?.mirrors).toEqual([]); + expect(second?.items?.map(({ depth, mirrored }) => [depth, mirrored])).toEqual([ + [0, false], + [1, false], + ]); + }); + + it("does not move a Thread that Desktop shows in more than one list", () => { + expect( + planSidebarThreadForest([ + [entry("parent")], + [entry("child", "parent")], + [entry("child", "parent")], + ]), + ).toEqual([ + { items: null, mirrors: [] }, + { items: null, mirrors: [] }, + { items: null, mirrors: [] }, + ]); + }); + + it("keeps a cross-list cycle in place", () => { + expect(planSidebarThreadForest([[entry("a", "b")], [entry("b", "a")]])).toEqual([ + { items: null, mirrors: [] }, + { items: null, mirrors: [] }, + ]); + }); +}); From 5ec9682bdeb83c06c505ca6b7daffbcaf33bb9ef Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Tue, 3 Nov 2026 16:21:29 +0800 Subject: [PATCH 3/7] fix(renderer): keep sidebar tree toggles out of the row layout The expand/collapse toggle was an in-flow flex item at the start of the title trigger, so a parent row's icon and title shifted right by the toggle width plus the row gap. Parents no longer lined up with sibling rows at the same depth, and their children appeared left of the parent's icon instead of one indent level to the right. Position the toggle absolutely with a negative start margin so it sits in the leading padding left of the icon without taking inline space. With no insets, it keeps its static position and stays vertically aligned with the icon. Siblings now align regardless of having a toggle, and children indent exactly 14px relative to their parent's icon. Signed-off-by: Vincent <1729065730@qq.com> --- docs/architecture/sidebar-thread-tree.md | 2 +- .../src/renderer-sidebar-thread-tree.ts | 35 ++++++++++++++----- .../test/renderer-sidebar-thread-tree.test.ts | 15 ++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md index 5d27b4337..79281ecf0 100644 --- a/docs/architecture/sidebar-thread-tree.md +++ b/docs/architecture/sidebar-thread-tree.md @@ -26,7 +26,7 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T - 跨列表嵌套: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]` 最前面,阻止事件冒泡到原生行,避免触发导航。折叠状态只保存在当前 Renderer 内存中。 +- 展开/折叠按钮插入在 `[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”的缓存;释放时恢复所有原始样式并移除按钮。 diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts index 09d7b260e..c3ebfe004 100644 --- a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -254,6 +254,31 @@ interface SavedItemStyle { display: string; } +const SIDEBAR_THREAD_TREE_TOGGLE_SIZE_PX = 14; +const SIDEBAR_THREAD_TREE_TOGGLE_GAP_PX = 4; + +/** + * Inline styles for the expand/collapse toggle. The toggle is taken out of + * flow and pulled into the row's leading padding, so a parent's icon and title + * stay aligned with siblings that have no toggle. With no insets set, the + * absolutely positioned toggle keeps its static position at the start of the + * title trigger (vertically aligned like the icon), and the negative start + * margin moves it left of the icon. + */ +export const SIDEBAR_THREAD_TREE_TOGGLE_STYLE = { + position: "absolute", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: `${SIDEBAR_THREAD_TREE_TOGGLE_SIZE_PX}px`, + height: `${SIDEBAR_THREAD_TREE_TOGGLE_SIZE_PX}px`, + marginInlineStart: `-${SIDEBAR_THREAD_TREE_TOGGLE_SIZE_PX + SIDEBAR_THREAD_TREE_TOGGLE_GAP_PX}px`, + flex: "none", + cursor: "pointer", + borderRadius: "3px", + opacity: "0.75", +} as const satisfies Partial>; + const savedItemStyles = new WeakMap(); const toggleHandlers = new WeakMap void>(); @@ -285,15 +310,7 @@ function createToggle(document: Document): HTMLElement { toggle.setAttribute(SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE, ""); toggle.setAttribute("role", "button"); toggle.tabIndex = 0; - toggle.style.display = "inline-flex"; - toggle.style.alignItems = "center"; - toggle.style.justifyContent = "center"; - toggle.style.width = "14px"; - toggle.style.height = "14px"; - toggle.style.flex = "none"; - toggle.style.cursor = "pointer"; - toggle.style.borderRadius = "3px"; - toggle.style.opacity = "0.75"; + Object.assign(toggle.style, SIDEBAR_THREAD_TREE_TOGGLE_STYLE); toggle.append(chevronIcon(document)); for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "dblclick"]) { toggle.addEventListener(type, stopRowEvent); diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts index 1c47ab655..59b4386e4 100644 --- a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -12,6 +12,8 @@ import { installRendererSidebarThreadTree, layoutSidebarThreadTree, planSidebarThreadForest, + SIDEBAR_THREAD_TREE_INDENT_PX, + SIDEBAR_THREAD_TREE_TOGGLE_STYLE, type SidebarThreadTreeDom, type SidebarThreadTreeItem, type SidebarThreadTreeLayout, @@ -590,3 +592,16 @@ describe("planSidebarThreadForest", () => { ]); }); }); + +describe("sidebar Thread tree toggle style", () => { + it("keeps the toggle out of the row's inline layout", () => { + // An in-flow toggle shifted a parent's icon right of its siblings' icons. + expect(SIDEBAR_THREAD_TREE_TOGGLE_STYLE.position).toBe("absolute"); + const width = Number.parseFloat(SIDEBAR_THREAD_TREE_TOGGLE_STYLE.width); + const margin = Number.parseFloat(SIDEBAR_THREAD_TREE_TOGGLE_STYLE.marginInlineStart); + expect(margin).toBeLessThan(-width); + // The toggle stays within one indent level left of the icon, so it never + // overlaps the parent's own icon or reaches past the row's leading gutter. + expect(-margin).toBeLessThanOrEqual(SIDEBAR_THREAD_TREE_INDENT_PX + 4); + }); +}); From f9bcb8d067bfdf084f191895ae0693d3c9029d33 Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Thu, 5 Nov 2026 16:54:26 +0800 Subject: [PATCH 4/7] fix(renderer): retain sidebar mirrors when Recents collapses Collapsing Recents unmounts fork rows while pinned/project parents stay visible; the forest then cleared mirrors and toggles. Keep previously applied mirrors (and parent toggles) when the parent remains mounted, including nested sticky subtrees. Cold start with Recents already collapsed still needs one expand to clone source DOM. Signed-off-by: Vincent <1729065730@qq.com> --- docs/architecture/sidebar-thread-tree.md | 3 +- .../src/renderer-sidebar-thread-tree.ts | 159 ++++++++++++++++-- .../test/renderer-sidebar-thread-tree.test.ts | 92 +++++++++- 3 files changed, 240 insertions(+), 14 deletions(-) diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md index 79281ecf0..4316674ae 100644 --- a/docs/architecture/sidebar-thread-tree.md +++ b/docs/architecture/sidebar-thread-tree.md @@ -32,6 +32,7 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T ## 已知边界 +- 子 Thread 所在分组(例如「最近」)折叠后,若父 Thread 仍留在其他列表(置顶/项目),已创建的镜像会保留在父 Thread 下,折叠按钮与子树不随源行卸载而消失。冷启动时若「最近」已折叠且从未挂载过子行,仍需展开一次才能建立镜像(没有可克隆的源 DOM)。 - 父 Thread 尚未渲染(未加载到当前页,或所在分组已折叠)时,子 Thread 留在原位置;父 Thread 再次出现后自动嵌套。 - 镜像不承接拖放、悬停卡片和只响应 pointerdown 的交互。 - 视觉顺序由 CSS `order` 决定;依赖 DOM 顺序的键盘循环仍按原生顺序。 @@ -40,4 +41,4 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T ## 验证 -`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、置顶父 Thread 的跨列表 Fork(含后出现的 Fork、折叠、父 Thread 消失和释放镜像)、跨列表的子树、同列表优先、重复 Thread 和跨列表环、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。 +`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。 diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts index c3ebfe004..a38f4cc40 100644 --- a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -53,6 +53,55 @@ export type SidebarThreadTreeLayoutPlan = Omit; + previousMirrors: ReadonlyMap; + parentKeyOf: (key: string) => string | null; + collapsed: ReadonlySet; +}): Map { + const listKeySet = new Set(input.listKeys.filter((key): key is string => key !== null)); + const result = new Map(); + + const canStick = (key: string, visiting: Set): boolean => { + const parent = input.parentKeyOf(key); + if (parent === null) return false; + if (listKeySet.has(parent) && input.mountedKeys.has(parent)) return true; + if (!input.previousMirrors.has(parent) || input.mountedKeys.has(parent)) return false; + if (visiting.has(parent)) return false; + visiting.add(parent); + return canStick(parent, visiting); + }; + + const hiddenUnderCollapsedAncestor = (key: string): boolean => { + let walk: string | null = key; + for (let depth = 0; walk !== null && depth < SIDEBAR_THREAD_TREE_MAX_ANCESTRY; depth += 1) { + const parent = input.parentKeyOf(walk); + if (parent === null) return false; + if (input.mountedKeys.has(parent) && input.collapsed.has(parent)) return true; + walk = parent; + } + return false; + }; + + for (const [key, previous] of input.previousMirrors) { + if (input.mountedKeys.has(key)) continue; + if (!canStick(key, new Set())) continue; + result.set(key, { + order: previous.order, + depth: previous.depth, + hidden: hiddenUnderCollapsedAncestor(key), + toggle: previous.toggle, + }); + } + return result; +} + export function layoutSidebarThreadTree( entries: readonly SidebarThreadTreeEntry[], collapsed: ReadonlySet = new Set(), @@ -232,8 +281,16 @@ export interface SidebarThreadTreeMirror { export interface SidebarThreadTreeList { /** Every direct native list entry, in DOM order, without mirrors. */ items(): readonly SidebarThreadTreeItem[]; - /** Show exactly these mirrors in this list; mirrors not listed are removed. */ - mirror(mirrors: readonly SidebarThreadTreeMirror[]): void; + /** + * Show exactly these mirrors in this list. `retain` keeps existing mirrors + * whose keys are listed (with the given layout) even when their native source + * row is gone — used when Recents collapses but the parent stays mounted. + * Keys not in `mirrors` or `retain` are removed. + */ + mirror( + mirrors: readonly SidebarThreadTreeMirror[], + retain?: ReadonlyMap, + ): void; } export interface SidebarThreadTreeDom { @@ -589,7 +646,10 @@ class BrowserSidebarThreadTreeList implements SidebarThreadTreeList { .map((child) => this.itemFor(child)); } - mirror(mirrors: readonly SidebarThreadTreeMirror[]): void { + mirror( + mirrors: readonly SidebarThreadTreeMirror[], + retain: ReadonlyMap = new Map(), + ): void { const existing = new Map(); for (const child of [...this.element.children]) { const key = child.getAttribute(SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE); @@ -617,6 +677,18 @@ class BrowserSidebarThreadTreeList implements SidebarThreadTreeList { layout.toggle, ); } + // Recents collapsed: keep clones that already show under a still-mounted parent. + for (const [key, layout] of retain) { + const mirror = existing.get(key); + if (!mirror) continue; + existing.delete(key); + applyItemLayout(mirror, layout, savedItemStyles.get(mirror)); + applyToggle( + mirror, + mirror.querySelector("[data-thread-title-trigger]"), + layout.toggle, + ); + } for (const stale of existing.values()) stale.remove(); } } @@ -755,6 +827,11 @@ export function installRendererSidebarThreadTree(options: { const retryAttempts = new Map(); const retryTimers = new Map>(); const collapsed = new Set(); + /** Last applied mirror layouts per list; retained when sources unmount. */ + const previousMirrors = new WeakMap< + SidebarThreadTreeList, + Map + >(); let nativeActive = 0; let disposed = false; let scanScheduled = false; @@ -932,22 +1009,82 @@ export function installRendererSidebarThreadTree(options: { : null, }); const plans = planSidebarThreadForest(entries, collapsed); + const mountedKeys = new Set(); + for (const listEntries of entries) { + for (const entry of listEntries) { + if (entry.key !== null) mountedKeys.add(entry.key); + } + } + const parentKeyFromMirrorKey = (key: string): string | null => { + let hostId: string; + try { + hostId = (JSON.parse(key) as [string, string])[0] ?? ""; + } catch { + return null; + } + return hostId === "" ? null : parentKeyOf(hostId, key); + }; + const retainByList = lists.map((list, listIndex) => { + const retain = stickyMirrorLayouts({ + listKeys: (entries[listIndex] ?? []).map((entry) => entry.key), + mountedKeys, + previousMirrors: previousMirrors.get(list) ?? new Map(), + parentKeyOf: parentKeyFromMirrorKey, + collapsed, + }); + for (const planned of plans[listIndex]?.mirrors ?? []) { + const key = entries[planned.list]?.[planned.index]?.key; + if (key) retain.delete(key); + } + return retain; + }); // Lay out native entries first so every mirror clones its settled source. + // When Recents unmounts every child, forest may return items:null; still + // apply a parent layout if sticky mirrors remain under that parent. listItems.forEach((items, listIndex) => { const listPlan = plans[listIndex]?.items ?? null; + const retain = retainByList[listIndex] ?? new Map(); + const stickyDirectByParent = new Map(); + for (const stickyKey of retain.keys()) { + const parent = parentKeyFromMirrorKey(stickyKey); + if (parent === null) continue; + stickyDirectByParent.set(parent, (stickyDirectByParent.get(parent) ?? 0) + 1); + } items.forEach((item, index) => { + const key = entries[listIndex]?.[index]?.key ?? null; const plan = listPlan?.[index]; - item.apply(plan ? layoutOf(plan, entries[listIndex]?.[index]?.key ?? null) : null); + const stickyDirect = key !== null ? (stickyDirectByParent.get(key) ?? 0) : 0; + if (!plan && stickyDirect === 0) { + item.apply(null); + return; + } + const childCount = (plan?.childCount ?? 0) + stickyDirect; + item.apply( + layoutOf( + { + order: plan?.order ?? index, + depth: plan?.depth ?? 0, + hidden: plan?.hidden ?? false, + childCount, + collapsed: key !== null && childCount > 0 && collapsed.has(key), + }, + key, + ), + ); }); }); lists.forEach((list, listIndex) => { - list.mirror( - (plans[listIndex]?.mirrors ?? []).flatMap(({ list: from, index, plan }) => { - const source = listItems[from]?.[index]; - const key = entries[from]?.[index]?.key; - return source && key ? [{ key, source, layout: layoutOf(plan, key) }] : []; - }), - ); + const live = (plans[listIndex]?.mirrors ?? []).flatMap(({ list: from, index, plan }) => { + const source = listItems[from]?.[index]; + const key = entries[from]?.[index]?.key; + return source && key ? [{ key, source, layout: layoutOf(plan, key) }] : []; + }); + const retain = retainByList[listIndex] ?? new Map(); + list.mirror(live, retain); + const nextPrevious = new Map(); + for (const mirror of live) nextPrevious.set(mirror.key, mirror.layout); + for (const [key, layout] of retain) nextPrevious.set(key, layout); + previousMirrors.set(list, nextPrevious); }); for (const [hostId, ids] of unresolved) { const client = options.getClient(hostId); diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts index 59b4386e4..e71719144 100644 --- a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -12,6 +12,8 @@ import { installRendererSidebarThreadTree, layoutSidebarThreadTree, planSidebarThreadForest, + stickyMirrorLayouts, + sidebarThreadDescendantKeys, SIDEBAR_THREAD_TREE_INDENT_PX, SIDEBAR_THREAD_TREE_TOGGLE_STYLE, type SidebarThreadTreeDom, @@ -51,8 +53,16 @@ class FakeList implements SidebarThreadTreeList { return this.mounted; } - mirror(mirrors: readonly SidebarThreadTreeMirror[]): void { - this.mirrors = mirrors; + mirror( + mirrors: readonly SidebarThreadTreeMirror[], + retain: ReadonlyMap = new Map(), + ): void { + const retained: SidebarThreadTreeMirror[] = []; + for (const previous of this.mirrors) { + const layout = retain.get(previous.key); + if (layout) retained.push({ key: previous.key, source: previous.source, layout }); + } + this.mirrors = [...mirrors, ...retained]; } } @@ -384,6 +394,84 @@ describe("Renderer sidebar Thread tree", () => { control.dispose(); }); + it("keeps mirrored children under a remaining parent when Recents unmounts the sources", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ forks: { fork: "parent" } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 1, 1, false, null]]); + expect(summary(pinned.mounted)).toEqual([ + ["parent", 0, 0, false, { collapsed: false, children: 1 }], + ]); + + // Recents section collapses: fork row unmounts; pinned parent stays visible. + recents.mounted = []; + dom.change(); + await settle(); + + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 1, 1, false, null]]); + expect(summary(pinned.mounted)).toEqual([ + ["parent", 0, 0, false, { collapsed: false, children: 1 }], + ]); + control.dispose(); + }); + + it("keeps a mirrored subtree when Recents unmounts nested sources", async () => { + const childKey = JSON.stringify(["local", "child"]); + const grandKey = JSON.stringify(["local", "grandchild"]); + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("child"), new FakeItem("grandchild")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ + relations: { + child: { parent: "parent" }, + grandchild: { parent: "child" }, + }, + }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + expect(mirrorSummary(pinned)).toEqual([ + [childKey, "child", 1, 1, false, { collapsed: false, children: 1 }], + [grandKey, "grandchild", 2, 2, false, null], + ]); + + recents.mounted = []; + dom.change(); + await settle(); + + expect(mirrorSummary(pinned)).toEqual([ + [childKey, "child", 1, 1, false, { collapsed: false, children: 1 }], + [grandKey, "grandchild", 2, 2, false, null], + ]); + expect(summary(pinned.mounted)).toEqual([ + ["parent", 0, 0, false, { collapsed: false, children: 1 }], + ]); + control.dispose(); + }); + + it("hides sticky mirrors when the remaining parent is collapsed", async () => { + const pinned = new FakeList([new FakeItem("parent")]); + const recents = new FakeList([new FakeItem("fork")]); + const dom = new FakeDom([pinned, recents]); + const client = clientWith({ forks: { fork: "parent" } }); + const control = installRendererSidebarThreadTree({ getClient: () => client, dom }); + await settle(); + + recents.mounted = []; + dom.change(); + await settle(); + pinned.mounted[0]?.layout?.toggle?.onToggle(); + await settle(); + + expect(summary(pinned.mounted)).toEqual([ + ["parent", 0, 0, false, { collapsed: true, children: 1 }], + ]); + expect(mirrorSummary(pinned)).toEqual([[forkKey, "fork", 1, 1, true, null]]); + control.dispose(); + }); + it("returns a mirrored child to its own list when the parent leaves the sidebar", async () => { const pinned = new FakeList([new FakeItem("parent")]); const recents = new FakeList([new FakeItem("fork")]); From cbe34d178083b5e932e086c9dc31fe13c3c141dc Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Thu, 5 Nov 2026 16:59:27 +0800 Subject: [PATCH 5/7] fix(renderer): define MAX_ANCESTRY for sticky mirror walk on nesting branch Cherry-pick of sticky mirrors onto feat/renderer-sidebar-thread-nesting referenced SIDEBAR_THREAD_TREE_MAX_ANCESTRY which only existed on the vincent/release lineage. Signed-off-by: Vincent <1729065730@qq.com> --- packages/renderer-extension/src/renderer-sidebar-thread-tree.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts index a38f4cc40..629eb42bf 100644 --- a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -19,6 +19,7 @@ export const SIDEBAR_THREAD_TREE_TOGGLE_ATTRIBUTE = "data-codexhost-sidebar-thre export const SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE = "data-codexhost-sidebar-thread-mirror"; export const SIDEBAR_THREAD_TREE_INDENT_PX = 14; const SIDEBAR_THREAD_TREE_MAX_INDENT_DEPTH = 6; +const SIDEBAR_THREAD_TREE_MAX_ANCESTRY = 64; const RELATION_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; const NATIVE_FORK_READ_CONCURRENCY = 4; From 9c9bc93d393ae1a7f18ea17d5b9d865efb5a796c Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Thu, 5 Nov 2026 18:16:04 +0800 Subject: [PATCH 6/7] fix(renderer): keep pinned child threads in Pinned under nesting Cross-list forest planning mirrored a pinned child back under its unpinned Recents parent, so only the root looked pinnable. Mark the Pinned list as retain-native so outbound mirrors are skipped while inbound nest-forks and same-list nesting still work. Signed-off-by: Vincent <1729065730@qq.com> --- docs/architecture/sidebar-thread-tree.md | 1 + .../src/renderer-sidebar-thread-tree.ts | 70 +++++++++- .../test/renderer-sidebar-thread-tree.test.ts | 126 ++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md index 4316674ae..6603d77f8 100644 --- a/docs/architecture/sidebar-thread-tree.md +++ b/docs/architecture/sidebar-thread-tree.md @@ -32,6 +32,7 @@ Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 T ## 已知边界 +- 置顶(Pinned)列表对「向外镜像」保留原生位置:单独置顶子 Thread 时,它留在置顶区作为独立行,不会被未置顶的祖先拽回「最近」并隐藏。置顶父 Thread 下的跨列表 Fork/委派子 Thread 仍会镜像进置顶列表。取消置顶子 Thread 后由 Desktop 把它移回原列表。若父 Thread 本身已置顶,同列表嵌套照常。 - 子 Thread 所在分组(例如「最近」)折叠后,若父 Thread 仍留在其他列表(置顶/项目),已创建的镜像会保留在父 Thread 下,折叠按钮与子树不随源行卸载而消失。冷启动时若「最近」已折叠且从未挂载过子行,仍需展开一次才能建立镜像(没有可克隆的源 DOM)。 - 父 Thread 尚未渲染(未加载到当前页,或所在分组已折叠)时,子 Thread 留在原位置;父 Thread 再次出现后自动嵌套。 - 镜像不承接拖放、悬停卡片和只响应 pointerdown 的交互。 diff --git a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts index 629eb42bf..c0678a110 100644 --- a/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -172,6 +172,16 @@ export interface SidebarThreadForestListPlan { mirrors: SidebarThreadForestMirrorPlan[]; } +export interface SidebarThreadForestOptions { + /** + * Per-list flag: when true, native entries of that list never leave for a + * cross-list parent (same-list nesting still applies). Mirrors *into* the + * list from elsewhere are unchanged. Used for Desktop's Pinned section so + * pinning a child alone is not undone by an unpinned ancestor in Recents. + */ + retainNativeList?: readonly boolean[]; +} + /** * Lay out all native lists together. Desktop files every Thread in exactly one * list, and a new Fork is not pinned, so the Fork of a pinned Thread lands in @@ -180,11 +190,17 @@ export interface SidebarThreadForestListPlan { * where Desktop placed it; inside each list `layoutSidebarThreadTree` applies. * A parent in the same list wins over one elsewhere. Only a key shown once * across all lists can move, and a cross-list cycle keeps its entries in place. + * + * Lists marked in `retainNativeList` (Pinned) keep their native rows even when + * the parent lives elsewhere, so a pinned child stays visible in Pinned on its + * own. Inbound mirrors from Recents into a pinned parent still work. */ export function planSidebarThreadForest( lists: readonly (readonly SidebarThreadTreeEntry[])[], collapsed: ReadonlySet = new Set(), + options: SidebarThreadForestOptions = {}, ): SidebarThreadForestListPlan[] { + const retainNative = options.retainNativeList ?? []; const counts = new Map(); const first = new Map(); const indexByKey = lists.map((entries, list) => { @@ -203,6 +219,8 @@ export function planSidebarThreadForest( if (entry.parentKey === entry.key || indexByKey[list]?.get(entry.key) !== index) return null; const sameList = indexByKey[list]?.get(entry.parentKey); if (sameList !== undefined) return { list, index: sameList }; + // Outbound retain: do not yank a pinned (etc.) row under an unpinned parent. + if (retainNative[list]) return null; if (counts.get(entry.key) !== 1) return null; return first.get(entry.parentKey) ?? null; }; @@ -292,6 +310,11 @@ export interface SidebarThreadTreeList { mirrors: readonly SidebarThreadTreeMirror[], retain?: ReadonlyMap, ): void; + /** + * When true, native rows stay in this list even if their parent lives + * elsewhere (Pinned). Defaults to false when omitted. + */ + retainNativePlacement?(): boolean; } export interface SidebarThreadTreeDom { @@ -632,12 +655,56 @@ function sidebarListItem(row: Element): HTMLElement | null { return item?.parentElement?.getAttribute("role") === "list" ? item : null; } +const SIDEBAR_SECTION_KEY_ATTRS = [ + "data-app-action-sidebar-section-heading", + "data-app-action-sidebar-section", +] as const; + +/** + * Closest preceding Desktop section heading/key node for a sidebar list. + * Sections are sequential in the scroll container (Pinned, then Recents, …), + * so the last heading that the list follows is this list's section. + */ +export function sidebarSectionHeadingForList(list: Element): HTMLElement | null { + const root = list.getRootNode(); + const scope = + typeof Document !== "undefined" && root instanceof Document + ? root + : (list.ownerDocument as Document | null); + if (!scope || typeof scope.querySelectorAll !== "function") return null; + let best: HTMLElement | null = null; + const following = typeof Node !== "undefined" ? Node.DOCUMENT_POSITION_FOLLOWING : 4; + for (const heading of scope.querySelectorAll( + "[data-app-action-sidebar-section-heading], [data-app-action-sidebar-section]", + )) { + const position = heading.compareDocumentPosition(list); + if (position & following) best = heading; + } + return best; +} + +/** True when the list is Desktop's Pinned section (native pin placement wins). */ +export function sidebarListRetainsNativePlacement(list: Element): boolean { + const heading = sidebarSectionHeadingForList(list); + if (!heading) return false; + for (const attr of SIDEBAR_SECTION_KEY_ATTRS) { + const raw = heading.getAttribute(attr); + if (!raw) continue; + if (raw.trim().toLowerCase() === "pinned") return true; + } + return false; +} + class BrowserSidebarThreadTreeList implements SidebarThreadTreeList { constructor( private readonly element: HTMLElement, private readonly itemFor: (element: HTMLElement) => BrowserSidebarThreadTreeItem, ) {} + retainNativePlacement(): boolean { + return sidebarListRetainsNativePlacement(this.element); + } + items(): readonly SidebarThreadTreeItem[] { return [...this.element.children] .filter( @@ -1009,7 +1076,8 @@ export function installRendererSidebarThreadTree(options: { } : null, }); - const plans = planSidebarThreadForest(entries, collapsed); + const retainNativeList = lists.map((list) => list.retainNativePlacement?.() === true); + const plans = planSidebarThreadForest(entries, collapsed, { retainNativeList }); const mountedKeys = new Set(); for (const listEntries of entries) { for (const entry of listEntries) { diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts index e71719144..24c2e2ffd 100644 --- a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -13,6 +13,8 @@ import { layoutSidebarThreadTree, planSidebarThreadForest, stickyMirrorLayouts, + sidebarListRetainsNativePlacement, + sidebarSectionHeadingForList, sidebarThreadDescendantKeys, SIDEBAR_THREAD_TREE_INDENT_PX, SIDEBAR_THREAD_TREE_TOGGLE_STYLE, @@ -679,6 +681,130 @@ describe("planSidebarThreadForest", () => { { items: null, mirrors: [] }, ]); }); + + it("keeps a pinned child in Pinned when its parent lives in Recents", () => { + // Pinning a child alone must not be undone by mirroring it back under an + // unpinned ancestor (the bug that made only the root appear pinnable). + const withoutRetain = planSidebarThreadForest([ + [entry("child", "parent"), entry("other-pinned")], + [entry("parent"), entry("recent")], + ]); + // Without retain, the pinned child is hidden and mirrored under Recents parent. + expect(withoutRetain[0]?.items?.map(({ mirrored }) => mirrored)).toEqual([true, false]); + expect(withoutRetain[1]?.mirrors.map(({ index, plan }) => [index, plan.depth])).toEqual([ + [0, 1], + ]); + + const [pinned, recents] = planSidebarThreadForest( + [ + [entry("child", "parent"), entry("other-pinned")], + [entry("parent"), entry("recent")], + ], + new Set(), + { retainNativeList: [true, false] }, + ); + + // With retain on Pinned: native layout kept (nothing nests inside Pinned), + // and Recents does not grow a mirror of the pinned child. + expect(pinned).toEqual({ items: null, mirrors: [] }); + expect(recents).toEqual({ items: null, mirrors: [] }); + }); + + it("still mirrors a Recents child under its pinned parent when Pinned retains", () => { + // Inbound nest-forks must keep working: only outbound yank is suppressed. + const [pinned, recents] = planSidebarThreadForest( + [ + [entry("parent"), entry("other-pinned")], + [entry("child", "parent"), entry("recent")], + ], + new Set(), + { retainNativeList: [true, false] }, + ); + + expect(pinned?.mirrors.map(({ list, index, plan }) => [list, index, plan.depth])).toEqual([ + [1, 0, 1], + ]); + expect(recents?.items?.map(({ mirrored }) => mirrored)).toEqual([true, false]); + }); + + it("still nests a pinned child under a pinned parent in the same list", () => { + const [pinned] = planSidebarThreadForest( + [[entry("parent"), entry("child", "parent")]], + new Set(), + { retainNativeList: [true] }, + ); + expect(pinned?.items?.map(({ depth, mirrored }) => [depth, mirrored])).toEqual([ + [0, false], + [1, false], + ]); + expect(pinned?.mirrors).toEqual([]); + }); +}); + +describe("sidebarListRetainsNativePlacement", () => { + it("treats the Desktop Pinned section as retain-native", () => { + const doc = { + querySelectorAll(selector: string) { + if (!selector.includes("section")) return [] as HTMLElement[]; + return [pinnedHeading, recentsHeading]; + }, + } as unknown as Document; + const pinnedHeading = { + getAttribute(name: string) { + return name.includes("heading") ? "Pinned" : null; + }, + compareDocumentPosition() { + return 4; // FOLLOWING relative to later lists + }, + ownerDocument: doc, + getRootNode() { + return doc; + }, + } as unknown as HTMLElement; + const recentsHeading = { + getAttribute(name: string) { + return name.includes("heading") ? "Recents" : null; + }, + compareDocumentPosition(other: Node) { + // Only the Recents list follows this heading. + return other === recentsList ? 4 : 0; + }, + ownerDocument: doc, + getRootNode() { + return doc; + }, + } as unknown as HTMLElement; + const pinnedList = { + ownerDocument: doc, + getRootNode() { + return doc; + }, + compareDocumentPosition() { + return 0; + }, + } as unknown as Element; + const recentsList = { + ownerDocument: doc, + getRootNode() { + return doc; + }, + compareDocumentPosition() { + return 0; + }, + } as unknown as Element; + + // Patch compareDocumentPosition on pinned heading to mark both lists as following + // in document order: pinned heading precedes both; recents heading only precedes recents. + ( + pinnedHeading as unknown as { compareDocumentPosition: (n: Node) => number } + ).compareDocumentPosition = (other: Node) => + other === pinnedList || other === recentsList ? 4 : 0; + + expect(sidebarSectionHeadingForList(pinnedList)).toBe(pinnedHeading); + expect(sidebarSectionHeadingForList(recentsList)).toBe(recentsHeading); + expect(sidebarListRetainsNativePlacement(pinnedList)).toBe(true); + expect(sidebarListRetainsNativePlacement(recentsList)).toBe(false); + }); }); describe("sidebar Thread tree toggle style", () => { From 3479b5de2c7f53aee9642b247a33a354532032ff Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Fri, 6 Nov 2026 00:19:28 +0800 Subject: [PATCH 7/7] test(renderer): drop unused sidebar tree test imports ESLint failed the Check job on two imports the sidebar tree test no longer uses: stickyMirrorLayouts and sidebarThreadDescendantKeys (the latter is not exported by the module on this branch). Signed-off-by: Vincent <1729065730@qq.com> --- .../test/renderer-sidebar-thread-tree.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts index 24c2e2ffd..0b4acbc53 100644 --- a/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -12,10 +12,8 @@ import { installRendererSidebarThreadTree, layoutSidebarThreadTree, planSidebarThreadForest, - stickyMirrorLayouts, sidebarListRetainsNativePlacement, sidebarSectionHeadingForList, - sidebarThreadDescendantKeys, SIDEBAR_THREAD_TREE_INDENT_PX, SIDEBAR_THREAD_TREE_TOGGLE_STYLE, type SidebarThreadTreeDom,