diff --git a/docs/architecture/sidebar-thread-tree.md b/docs/architecture/sidebar-thread-tree.md new file mode 100644 index 000000000..6603d77f8 --- /dev/null +++ b/docs/architecture/sidebar-thread-tree.md @@ -0,0 +1,45 @@ +# 侧边栏父子 Thread 嵌套 + +Codex Desktop 左侧边栏把 CodexHost 委派出的子 Thread 和 Fork 出的 Thread 缩进显示在父 Thread 下面。父 Thread 前出现展开/折叠按钮。该功能只改变 Renderer 的呈现,不改变执行归属、Thread 身份、导航或官方 Codex 的列表结果。 + +## 数据来源 + +关系只读取已持久化的数据,不打开 Harness Session、不读取 Snapshot、不加载 Turn: + +| 关系 | 来源 | 读取方式 | +| --- | --- | --- | +| 委派 | Mapping Store 中 Delegation 的 `parentHostThreadId` → `childHostThreadId` | Host 本地方法 `codexhost/thread/relation/list` | +| 外部 Harness Fork | 外部 Thread 记录的 `forkSource.hostThreadId` | 同上 | +| native Codex Fork | 官方 `thread/read`(`includeTurns: false`)返回的 `forkedFromId` | Renderer 通过既有 Host 通道请求,仅对 Host 报告为 Codex 所有、且没有委派关系的 Thread 读取 | + +同一 Thread 同时有委派和 Fork 来源时,以委派为准。官方 Subagent(`parentThreadId` 或 `source.subAgent`)和外部 Subagent 记录由 Desktop 原生嵌套,这里不重复处理。SSH 共享 owner 上的 Thread 由其他 Host 持有,本机不报告其关系。 + +`codexhost/thread/relation/list` 是新增的最小只读方法,参数与 `codexhost/thread/ownership/list` 相同(1–100 个不重复 Thread ID),返回每个 Thread 的 `owner` 和可选 `parent: { threadId, relation: "delegation" | "fork" }`。它不经过官方 Codex。Delegation 此前未暴露给 Renderer,所以需要这个方法;ownership 结果是严格 schema,不在原方法上扩字段。旧 Host 返回方法不可用时,Renderer 退回 ownership 列表,只保留 native Fork 嵌套;stock Codex 两者都没有时,所有 Thread 视为 native。 + +## Renderer 绑定 + +`renderer-sidebar-thread-tree.ts` 与侧边栏 Agent 图标共用同一套绑定方式: + +- 用 Fiber 中与行属性一致的 `conversationId` 识别 Thread(`threadIdFromSidebarRowElement`),按 Host 区分相同 ID。 +- `MutationObserver` 只在侧边栏行、列表或本功能标记的节点变化时调度一次 `requestAnimationFrame` 全量扫描。React 重渲染或替换节点后,下一次扫描重新应用布局,不依赖一次性 DOM 修改。 +- 不移动、不包裹、不删除原生节点。只在原生 `role="listitem"` 上设置 CSS `order`、行首缩进和折叠时的 `display: none`,并保存原值;没有嵌套的列表不改样式。React 的节点协调和原生点击导航保持不变。 +- 跨列表嵌套:Desktop 把每个 Thread 只放进一个列表,新 Fork 不继承置顶,所以置顶 Thread 的 Fork 出现在“最近”或项目列表里,与父 Thread 不在同一列表。所有列表一起规划(`planSidebarThreadForest`):同列表内的父 Thread 优先;否则子 Thread 及其子树显示在根祖先所在的列表中。原生子行在原列表中以 `display: none` 隐藏,父列表末尾追加一个 CodexHost 自有的镜像节点(`data-codexhost-sidebar-thread-mirror`),用 CSS `order` 和缩进排到父 Thread 下面。 +- 镜像是原生列表项的深拷贝,去掉 `id`、`draggable`、行识别属性(`data-app-action-sidebar-thread-row`/`-id`/`-host-id`)、键盘循环和拖放属性,保留选中、置顶等视觉属性;原生行标记或内容变化时重新拷贝。镜像上的 click、dblclick、auxclick、contextmenu 及 Enter/空格键在原生子行的对应节点上重放,导航、右键菜单和行内按钮仍由 Desktop 处理。只有在全部列表中只出现一次的 Thread 才会跨列表移动;跨列表的环保持原位。 +- 只有值变化时才写样式或属性,扫描是幂等的,自身写入不会形成观察循环。 +- 展开/折叠按钮插入在 `[data-thread-title-trigger]` 最前面,绝对定位并用负的行首外边距放到图标左侧的行首留白中,不占行内宽度,所以有子 Thread 的行与同层级行的图标对齐,子 Thread 相对父 Thread 图标恰好缩进一级(14 px)。按钮阻止事件冒泡到原生行,避免触发导航。折叠状态只保存在当前 Renderer 内存中。 +- 关系结果按 Host 批量请求,单批最多 100 个。委派记录可能晚于子行出现,没有父 Thread 的结果在 1 s、3 s、10 s 后各重试一次;读取失败的行保持顶层。native Fork 读取并发上限为 4,结果在 Renderer 生命周期内缓存。 +- Renderer 绑定刷新时,清除失败和“尚无父 Thread”的缓存;释放时恢复所有原始样式并移除按钮。 + +## 已知边界 + +- 置顶(Pinned)列表对「向外镜像」保留原生位置:单独置顶子 Thread 时,它留在置顶区作为独立行,不会被未置顶的祖先拽回「最近」并隐藏。置顶父 Thread 下的跨列表 Fork/委派子 Thread 仍会镜像进置顶列表。取消置顶子 Thread 后由 Desktop 把它移回原列表。若父 Thread 本身已置顶,同列表嵌套照常。 +- 子 Thread 所在分组(例如「最近」)折叠后,若父 Thread 仍留在其他列表(置顶/项目),已创建的镜像会保留在父 Thread 下,折叠按钮与子树不随源行卸载而消失。冷启动时若「最近」已折叠且从未挂载过子行,仍需展开一次才能建立镜像(没有可克隆的源 DOM)。 +- 父 Thread 尚未渲染(未加载到当前页,或所在分组已折叠)时,子 Thread 留在原位置;父 Thread 再次出现后自动嵌套。 +- 镜像不承接拖放、悬停卡片和只响应 pointerdown 的交互。 +- 视觉顺序由 CSS `order` 决定;依赖 DOM 顺序的键盘循环仍按原生顺序。 +- 层级缩进最多 6 级,更深的层级按第 6 级显示。 +- 新增 Host 方法需要重启 CodexHost 后生效;只更新 Renderer 时只有 native Fork 嵌套。 + +## 验证 + +`renderer-sidebar-thread-tree.test.ts` 覆盖布局、环、置顶父 Thread 的跨列表 Fork(含后出现的 Fork、折叠、父 Thread 消失、最近分组卸载后 sticky 镜像保留和释放镜像)、跨列表的子树、同列表优先、重复 Thread 和跨列表环、折叠、重渲染、Host 区分、旧 Host 回退、迟到委派重试和释放;`renderer-model-client.test.ts` 覆盖方法结果校验和回退;`thread-relation-list.test.ts` 与 `app-server-host.thread-relations.test.ts` 覆盖 Host 关系读取、参数校验、存储失败和不写官方 stdin。 diff --git a/docs/index.md b/docs/index.md index 3b08cc876..5805324be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,7 @@ | [`architecture/external-thread-media.md`](architecture/external-thread-media.md) | 外部 Thread 中本地图片的 Desktop 加载条件、Agent 消息媒体路径改写和用户消息图片附件还原;修改媒体显示或排查图片不显示时阅读。 | | [`architecture/delegation-titles.md`](architecture/delegation-titles.md) | 委派子会话标题、子任务显示名、创建快照与重试边界。 | | [`architecture/delegation-thread-list.md`](architecture/delegation-thread-list.md) | 委派 CLI 列表的共享 catalog、独立来源分页、查询绑定与活列表一致性边界。 | +| [`architecture/sidebar-thread-tree.md`](architecture/sidebar-thread-tree.md) | 侧边栏按委派和 Fork 关系缩进父子 Thread 的数据来源、Renderer 绑定与边界。 | | [`architecture/thread-watch.md`](architecture/thread-watch.md) | 一次性 Thread 停下通知(`thread watch`)的模型、结果、送达与边界;修改委派跟进或通知时阅读。 | | [`architecture/external-clients.md`](architecture/external-clients.md) | 同用户 CLI 自动认证、命名客户端登记、本机监听、Thread 引用、事件日志与 MCP;修改外部接入时阅读。 | | [`architecture/official-traffic-ownership.md`](architecture/official-traffic-ownership.md) | Host 只截获自身流量、其余原样转发官方的归属判别与例外;修改请求路由或参数校验时阅读。 | diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index 081732ae0..8017ad892 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -18,6 +18,7 @@ import { threadCatalogReadParamsSchema, } from "@codexhost/shared-contracts"; import { requestDesktopRemoteConnections } from "@codexhost/desktop-control"; +import { listThreadRelations } from "./thread-relation-list.js"; import type { RuntimeMaintenance } from "./runtime-maintenance.js"; import { readThreadCatalog } from "./thread-catalog.js"; import { ExternalThreadPrewarms } from "./external-thread-prewarms.js"; @@ -119,6 +120,7 @@ import { threadPermissionModeSelectParamsSchema, threadThinkingSelectParamsSchema, threadOwnershipListParamsSchema, + threadRelationListParamsSchema, threadOwnershipListResultSchema, threadSteeringInspectParamsSchema, threadSteeringInspectResultSchema, @@ -1011,6 +1013,7 @@ export class AppServerHost { inspectThread: (request) => this.#inspectThread(request), inspectThreadUsage: (request) => this.#inspectThreadUsage(request), listThreadOwnership: (request) => this.#listThreadOwnership(request), + listThreadRelations: (request) => this.#listThreadRelations(request), inspectThreadSteering: (request) => this.#inspectThreadSteering(request), handleSharedQuestionRequest: (request) => this.#handleSharedQuestionRequest(request), selectThreadModel: (request) => this.#selectThreadModel(request), @@ -3291,6 +3294,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/desktop-thread-routing.ts b/packages/host-runtime/src/desktop-thread-routing.ts index 33449f622..622c47296 100644 --- a/packages/host-runtime/src/desktop-thread-routing.ts +++ b/packages/host-runtime/src/desktop-thread-routing.ts @@ -20,6 +20,7 @@ import { } from "@codexhost/protocol-core"; import { THREAD_QUESTIONS_LIST_METHOD, + THREAD_RELATION_LIST_METHOD, THREAD_QUESTION_RESPOND_METHOD, harnessCommandsInspectParamsSchema, type HarnessId, @@ -95,6 +96,7 @@ export interface DesktopThreadRoutingHost { inspectThread(request: JsonRpcRequest): Promise; inspectThreadUsage(request: JsonRpcRequest): Promise; listThreadOwnership(request: JsonRpcRequest): Promise; + listThreadRelations(request: JsonRpcRequest): Promise; inspectThreadSteering(request: JsonRpcRequest): Promise; handleSharedQuestionRequest(request: JsonRpcRequest): Promise; selectThreadModel(request: JsonRpcRequest): Promise; @@ -220,6 +222,10 @@ export class DesktopThreadRouting { await this.#host.listThreadOwnership(request); return true; } + if (request.method === THREAD_RELATION_LIST_METHOD) { + await this.#host.listThreadRelations(request); + return true; + } if ( request.method === THREAD_QUESTIONS_LIST_METHOD || request.method === THREAD_QUESTION_RESPOND_METHOD 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 9112583dd..c9669f269 100644 --- a/packages/renderer-extension/src/renderer-binding-probe.ts +++ b/packages/renderer-extension/src/renderer-binding-probe.ts @@ -103,6 +103,7 @@ import { installRendererSidebarAgentIcons, SIDEBAR_THREAD_ROW_SELECTOR, } from "./renderer-sidebar-agent-icons.js"; +import { installRendererSidebarThreadTree } from "./renderer-sidebar-thread-tree.js"; import { rendererHarnessCommandExecutesDirectly, routeRendererHarnessCommandSelection, @@ -846,6 +847,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({ @@ -1491,6 +1495,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); @@ -1549,6 +1554,7 @@ export function installRendererBindingProbe( void loadThreadOwnership(mounted); } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); return true; }; @@ -2301,6 +2307,7 @@ export function installRendererBindingProbe( void refreshCommands(mounted); } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); return switched; } catch { adapterStatus = { @@ -2507,6 +2514,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); } @@ -2672,7 +2680,10 @@ export function installRendererBindingProbe( renderMounted(mounted); void loadThreadOwnership(mounted); } - if (changed) sidebarAgentIcons.refresh(); + if (changed) { + sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); + } }; function reconcileHarnessAvailabilityHost(retryDraftUsage = false): void { @@ -2724,7 +2735,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 || @@ -2965,6 +2979,7 @@ export function installRendererBindingProbe( } renderMounted(mounted); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); if (threadIdFromComposerModelTarget(modelTarget) && !inherited) { void loadThreadOwnership(mounted); } else if ( @@ -3270,6 +3285,7 @@ export function installRendererBindingProbe( } } sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); void refreshHarnessAvailability(); @@ -3290,6 +3306,7 @@ export function installRendererBindingProbe( reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); void refreshHarnessAvailabilityForHost("local"); void refreshHarnessAvailability(); } @@ -3543,6 +3560,7 @@ export function installRendererBindingProbe( harnessAvailabilityByHost.clear(); activeAvailabilityHostId = "local"; sidebarAgentIcons.refresh(); + sidebarThreadTree.refresh(); void refreshHarnessAvailabilityForHost("local"); reconcileHarnessAvailabilityHost(); void loadCodexAccounts(); @@ -3587,6 +3605,7 @@ export function installRendererBindingProbe( disposeReasoningSoftWrap(); delegationMention?.dispose(); sidebarAgentIcons.dispose(); + sidebarThreadTree.dispose(); sidebarThreadState.dispose(); nativeScheduleControls.dispose(); settingsLifecycle.dispose(); diff --git a/packages/renderer-extension/src/renderer-model-client.ts b/packages/renderer-extension/src/renderer-model-client.ts index 5dd4c5368..ea70eff93 100644 --- a/packages/renderer-extension/src/renderer-model-client.ts +++ b/packages/renderer-extension/src/renderer-model-client.ts @@ -84,6 +84,9 @@ import { threadThinkingSelectParamsSchema, threadOwnershipListParamsSchema, threadOwnershipListResultSchema, + threadRelationListParamsSchema, + threadRelationListResultSchema, + THREAD_RELATION_LIST_METHOD, threadUsageInspectionParamsSchema, threadUsageInspectionSchema, updateCheckResultSchema, @@ -111,6 +114,9 @@ import { type ThreadThinkingSelectParams, type ThreadOwnershipListParams, type ThreadOwnershipListResult, + type HostThreadId, + type ThreadRelationListParams, + type ThreadRelationListResult, type ThreadUsageInspection, type ThreadUsageInspectionParams, type UpdateCheckResult, @@ -125,7 +131,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 { HARNESS_PREFERENCES_GET_METHOD, HARNESS_PREFERENCES_PATCH_METHOD, @@ -245,6 +251,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; @@ -541,6 +551,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..c0678a110 --- /dev/null +++ b/packages/renderer-extension/src/renderer-sidebar-thread-tree.ts @@ -0,0 +1,1207 @@ +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_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 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; + +/** 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. + */ +/** + * When a mirrored child unmounts (e.g. Recents collapsed) but an ancestor stays + * mounted in this list, keep that mirror under the ancestor instead of clearing it. + * Cold start with the child never mounted cannot create a mirror (no source DOM). + */ +export function stickyMirrorLayouts(input: { + listKeys: readonly (string | null)[]; + mountedKeys: ReadonlySet; + 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(), +): 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 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[]; +} + +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 + * 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. + * + * 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) => { + 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 }; + // 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; + }; + 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, without mirrors. */ + items(): readonly SidebarThreadTreeItem[]; + /** + * 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; + /** + * 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 { + 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 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>(); + +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; + 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); + } + 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; +} + +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(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, + }); + } + if (!element.hasAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE)) + element.setAttribute(SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE, ""); + applyItemLayout(element, layout, savedItemStyles.get(element)); + applyToggle( + element, + this.#row()?.querySelector("[data-thread-title-trigger]") ?? null, + layout.toggle, + ); + } +} + +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); +} + +/** + * 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; +} + +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( + (child): child is HTMLElement => + child instanceof HTMLElement && !child.hasAttribute(SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE), + ) + .map((child) => this.itemFor(child)); + } + + 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); + 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, + ); + } + // 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(); + } +} + +/** 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 => { + 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[] { + 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 and mirrors. + for (const item of this.root.querySelectorAll( + `[${SIDEBAR_THREAD_TREE_ITEM_ATTRIBUTE}], [${SIDEBAR_THREAD_TREE_MIRROR_ATTRIBUTE}]`, + )) { + if (item.parentElement) lists.add(item.parentElement); + else clearSidebarThreadTreeItem(item); + } + 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(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(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) || + [...mutation.removedNodes].some(affectsSidebar) + ) { + onChange(); + return; + } + } + }); + observer.observe(this.root, { + attributes: true, + attributeFilter: [ + SIDEBAR_THREAD_ROW_ATTRIBUTE, + SIDEBAR_THREAD_ID_ATTRIBUTE, + SIDEBAR_THREAD_HOST_ID_ATTRIBUTE, + ...MIRRORED_ROW_ATTRIBUTES, + "role", + "style", + ], + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + } + + 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}]`, + )) { + 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(); + /** 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; + + 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>(); + 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); + 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 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 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) { + 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]; + 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) => { + 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); + 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 80afc32ea..370feae12 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 39df89c2b..078595ebf 100644 --- a/packages/renderer-extension/test/renderer-model-client.test.ts +++ b/packages/renderer-extension/test/renderer-model-client.test.ts @@ -443,11 +443,13 @@ describe("Renderer fixed Model request client", () => { "listLoadedSessions", "listSessionImportSources", "listThreadOwnership", + "listThreadRelations", "openConsole", "openHarnessWebUi", "openWeb", "patchHarnessPreferences", "readDelegationThread", + "readNativeThreadForkSource", "readUpdateStatus", "refreshCodexAccounts", "runtimeStatus", @@ -889,6 +891,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..0b4acbc53 --- /dev/null +++ b/packages/renderer-extension/test/renderer-sidebar-thread-tree.test.ts @@ -0,0 +1,819 @@ +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, + planSidebarThreadForest, + sidebarListRetainsNativePlacement, + sidebarSectionHeadingForList, + SIDEBAR_THREAD_TREE_INDENT_PX, + SIDEBAR_THREAD_TREE_TOGGLE_STYLE, + 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); + +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 { + mirrors: readonly SidebarThreadTreeMirror[] = []; + + constructor(public mounted: FakeItem[]) {} + + items(): readonly SidebarThreadTreeItem[] { + return this.mounted; + } + + 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]; + } +} + +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 { + 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) { + list.mirror([]); + 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("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("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")]); + 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); + + 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 () => { + 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); + }); +}); + +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: [] }, + ]); + }); + + 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", () => { + 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); + }); +}); diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index f0578d899..a530fc309 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -13,6 +13,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 * from "./web.js"; export * from "./web-tool-output.js"; export { 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;