From e58ad1d84bcb43a01f9fb31b77672934dd483a4f 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/2] fix(web): deleting a thread with side chats offers to delete its worktree Co-Authored-By: Claude Opus 5.5 --- .../useThreadActions.permissions.test.ts | 33 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 16 ++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index de5056c4af696..83032f5a59a52 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -31,6 +31,12 @@ const state = vi.hoisted(() => ({ projectId: ProjectId; title: string; worktreePath: string | null; + lineage: { + parentThreadId: ThreadId | null; + relationshipToParent: "fork" | null; + rootThreadId: ThreadId; + sideChat?: boolean; + }; session: { status: "ready" | "stopped" } | null; latestTurn: null; }[], @@ -238,6 +244,7 @@ beforeEach(() => { projectId: ProjectId.make("project"), title: "Thread", worktreePath: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: target.threadId }, session: null, latestTurn: null, }, @@ -348,6 +355,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("does not request worktree removal after its grant is revoked during delete", async () => { state.scopes .get(secondary)! diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e5eedffc16698..e90dc0e7869a9 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -469,10 +469,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); @@ -490,7 +496,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 779063da15bef6b3e1c3f9dfe9fa812ec6e30be0 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/2] 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 | 20 ++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 179971019e6e3..4a1735409f09f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1699,6 +1699,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 @@ -1710,11 +1718,20 @@ 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 onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); 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 fork keeps its deleted source's root, so the deleted root still names the live tree. yield* store.apply({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ab8d69f9a66d1..191d36b3f4b59 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -93,6 +93,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 { isThreadHistoryUserTurn, @@ -973,6 +974,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; @@ -6001,6 +6003,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, @@ -6194,7 +6197,11 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` + : // Loose match of any spelling of the path; readShellSnapshotRows keeps exact ones. + // SQLite's lower() folds only ASCII, so other paths always reach that check. + sql` AND (json_extract(t.payload_json, '$.worktreePath') GLOB '*[^ -~]*' + OR lower(rtrim(replace(trim(json_extract(t.payload_json, '$.worktreePath')), '\\', '/'), '/')) + = lower(rtrim(replace(trim(${worktreePath}), '\\', '/'), '/')))` }${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 `; @@ -6734,7 +6741,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),