From a64eefd5f45fe3c4663f8eb7fe3f25cc7e8515cb Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:51:09 +1100 Subject: [PATCH 1/4] fix(web): deleting a thread with side chats offers to delete its worktree Co-Authored-By: Claude Opus 5.5 --- .../useThreadActions.permissions.test.ts | 43 +++++++++++++++++-- apps/web/src/hooks/useThreadActions.ts | 16 ++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 649ec0c6bac1..d1a74e4e894f 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -33,7 +33,12 @@ const state = vi.hoisted(() => ({ projectId: ProjectId; title: string; worktreePath: string | null; - lineage: { parentThreadId: ThreadId | null; sideChat?: boolean }; + lineage: { + parentThreadId: ThreadId | null; + relationshipToParent: "fork" | null; + rootThreadId: ThreadId; + sideChat?: boolean; + }; session: { status: "ready" | "stopped" } | null; latestTurn: null; }[], @@ -269,7 +274,7 @@ beforeEach(() => { projectId: ProjectId.make("project"), title: "Thread", worktreePath: null, - lineage: { parentThreadId: null }, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: target.threadId }, session: null, latestTurn: null, }, @@ -424,6 +429,32 @@ describe("thread action permissions", () => { expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); + it("offers to delete the worktree that only its side chats share", async () => { + state.scopes + .get(secondary)! + .add(AuthOrchestrationOperateScope) + .add(AuthSourceControlWriteScope); + state.threads[0]!.worktreePath = "/worktrees/thread"; + state.threads.push({ + ...state.threads[0]!, + id: ThreadId.make("side-chat"), + lineage: { + parentThreadId: target.threadId, + relationshipToParent: "fork", + rootThreadId: target.threadId, + sideChat: true, + }, + }); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(state.confirm).toHaveBeenCalledOnce(); + expect(state.requests.map((request) => request.action)).toEqual([ + "stopSession", + "delete", + "removeWorktree", + "refreshStatus", + ]); + }); + it("clears the drafts of the side chats deleted with it", async () => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); const parent = state.threads[0]!; @@ -431,9 +462,13 @@ describe("thread action permissions", () => { { ...parent, id: ThreadId.make("side-chat"), - lineage: { parentThreadId: parent.id, sideChat: true }, + lineage: { ...parent.lineage, parentThreadId: parent.id, sideChat: true }, + }, + { + ...parent, + id: ThreadId.make("fork"), + lineage: { ...parent.lineage, parentThreadId: parent.id }, }, - { ...parent, id: ThreadId.make("fork"), lineage: { parentThreadId: parent.id } }, ); expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); expect(state.localEffects).toEqual( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cc9c27770886..c16f34f23df2 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -501,10 +501,16 @@ export function useThreadActions() { }), ) : undefined; - const survivingThreads = - deletedIds && deletedIds.size > 0 - ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) - : threads; + // Deleting a thread also deletes its side chats, which share its worktree. + const removedIds = new Set(deletedIds); + for (const entry of threads) { + if (entry.lineage.sideChat === true && entry.lineage.parentThreadId === threadRef.threadId) { + removedIds.add(entry.id); + } + } + const survivingThreads = threads.filter( + (entry) => entry.id === threadRef.threadId || !removedIds.has(entry.id), + ); const environmentConfig = appAtomRegistry .get(environmentServerConfigsAtom) .get(threadRef.environmentId); @@ -522,7 +528,7 @@ export function useThreadActions() { if ( occupants._tag !== "Success" || occupants.value.threadIds.some( - (threadId) => threadId !== threadRef.threadId && deletedIds?.has(threadId) !== true, + (threadId) => threadId !== threadRef.threadId && !removedIds.has(threadId), ) ) { orphanedWorktreePath = null; From 686f67826777f34fdb847d6adc6225ab1f61b9ba Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:37:57 +1100 Subject: [PATCH 2/4] fix(server): worktree thread lookups match any spelling of the path Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 21 +++++++++++-- .../src/orchestration-v2/ProjectionStore.ts | 30 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 50f2a0a12d1a..7dcc695e605b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2795,6 +2795,14 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); yield* createThread({ name: "other", worktreePath: "/repo" }); yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); + yield* createThread({ + name: "aliased-child", + parent: "other", + root: "other", + worktreePath: `${worktreePath}/`, + }); + yield* createThread({ name: "cased", worktreePath: worktreePath.toUpperCase() }); + yield* createThread({ name: "windows", worktreePath: "C:\\RÉPO\\wt" }); const family = (threadId: ThreadId) => store @@ -2806,14 +2814,23 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.sameMembers([...(yield* family(id("grandchild")))], tree); assert.deepEqual([...(yield* family(id("missing")))], []); - // Includes another root's idle child on the same path, which a shell may leave out. + // Includes another root's idle child on the same path, which a shell may leave out, under any + // spelling of that path. POSIX paths keep their case. const { statements, tracer } = traceSqlStatements(); const onWorktree = yield* store .getShellSnapshot({ location: "active", worktreePath }) .pipe(Effect.withTracer(tracer)); assert.sameMembers( onWorktree.threads.map((thread) => thread.id), - [id("root"), id("child"), id("other-child")], + [id("root"), id("child"), id("other-child"), id("aliased-child")], + ); + const onWindowsWorktree = yield* store.getShellSnapshot({ + location: "active", + worktreePath: "c:/répo/wt/", + }); + assert.deepEqual( + onWindowsWorktree.threads.map((thread) => thread.id), + [id("windows")], ); // A path names a few threads; scanning every thread parses every payload. const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 431426270f22..c7322e578ffc 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -95,6 +95,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; import { readableSearchText } from "./messageSearchIndex.ts"; import { @@ -1095,6 +1096,7 @@ type ShellThreadRow = { readonly thread_id: string; readonly payload_json: string; readonly forked_from_run_source_thread_id: string | null; + readonly worktree_path: string | null; readonly latest_run_id: string | null; readonly latest_run_status: string | null; readonly latest_run_requested_at: string | null; @@ -7687,6 +7689,7 @@ export const layer: Layer.Layer = THEN json_extract(t.payload_json, '$.forkedFrom.threadId') ELSE NULL END AS forked_from_run_source_thread_id, + json_extract(t.payload_json, '$.worktreePath') AS worktree_path, presented.run_id AS latest_run_id, presented.status AS latest_run_status, presented.requested_at AS latest_run_requested_at, @@ -7883,8 +7886,20 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND CASE WHEN json_valid(t.payload_json) - THEN json_extract(t.payload_json, '$.worktreePath') END = ${worktreePath}` + : // The key matches orchestration_v2_projection_threads_worktree_path_key_idx, so an + // ASCII path does not scan the table. SQLite lower() folds only ASCII, so a query + // that itself contains another character also admits those stored paths; + // readShellSnapshotRows keeps the exact comparison. + sql` AND (CASE WHEN json_valid(t.payload_json) + THEN lower(rtrim(replace(trim(json_extract(t.payload_json, '$.worktreePath')), '\\', '/'), '/')) + END = lower(rtrim(replace(trim(${worktreePath}), '\\', '/'), '/'))${ + /^[\u0000-\u007e]*$/u.test(worktreePath) + ? sql`` + : sql` OR ( + json_valid(t.payload_json) + AND json_extract(t.payload_json, '$.worktreePath') GLOB '*[^ -~]*' + )` + })` }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -8498,7 +8513,16 @@ export const layer: Layer.Layer = const readShellSnapshotRows = Effect.fn("ProjectionStore.readShellSnapshotRows")(function* ( options: ShellSnapshotOptions | undefined, ) { - const targetThreadRows = yield* selectShellThreadRows(undefined, options); + const worktreeKey = + options?.worktreePath === undefined + ? undefined + : normalizeProjectPathForComparison(options.worktreePath); + const targetThreadRows = (yield* selectShellThreadRows(undefined, options)).filter( + (row) => + worktreeKey === undefined || + (row.worktree_path !== null && + normalizeProjectPathForComparison(row.worktree_path) === worktreeKey), + ); const targetThreadIds = new Set(targetThreadRows.map((row) => ThreadId.make(row.thread_id))); const rowsByThreadId = new Map( targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), From 6918807e83fe854ed17eb57da7e1112b45e54e5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:51:11 +1100 Subject: [PATCH 3/4] fix(server): index the loose worktree path lookup Main's shell read must not scan every thread. ASCII path queries use an indexed normalized key; a query with a non-ASCII character still admits those stored paths for the exact comparison. --- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/092_WorktreePathKeyIndex.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f1b8f6c9d806..cf1f60cd253f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -99,6 +99,7 @@ import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; import Migration0089 from "./Migrations/089_ShellThreadReadIndexes.ts"; import Migration0090 from "./Migrations/090_AuthConnectDevices.ts"; import Migration0091 from "./Migrations/091_AuthConnectDeniedRequests.ts"; +import Migration0092 from "./Migrations/092_WorktreePathKeyIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -193,6 +194,7 @@ const migrationEntries = [ [89, "ShellThreadReadIndexes", Migration0089], [90, "AuthConnectDevices", Migration0090], [91, "AuthConnectDeniedRequests", Migration0091], + [92, "WorktreePathKeyIndex", Migration0092], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts b/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts new file mode 100644 index 000000000000..9b1b42647d60 --- /dev/null +++ b/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep this expression identical to ProjectionStore's loose worktree filter. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_worktree_path_key_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) + THEN lower(rtrim(replace(trim(json_extract(payload_json, '$.worktreePath')), '\\', '/'), '/')) + END + ) + `; +}); From a545ab48517116a2a36086d805b84d807307131c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:55:56 +1100 Subject: [PATCH 4/4] test(server): expect the worktree path key migration The preview upgrade ledger lists every migration, including 092. --- apps/server/src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 89c06944d2bf..1e19762a7afd 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -71,6 +71,7 @@ describe("V2 preview upgrade", () => { [89, "ShellThreadReadIndexes"], [90, "AuthConnectDevices"], [91, "AuthConnectDeniedRequests"], + [92, "WorktreePathKeyIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -185,6 +186,7 @@ describe("V2 preview upgrade", () => { [89, "ShellThreadReadIndexes"], [90, "AuthConnectDevices"], [91, "AuthConnectDeniedRequests"], + [92, "WorktreePathKeyIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), );