diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1ca7eb67036fa..5c944096ed89d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1974,6 +1974,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 @@ -1985,11 +1993,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 3d33f76b7a494..7b25e74a4f23f 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 { isThreadHistoryUserTurn, @@ -1024,6 +1025,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; @@ -6215,6 +6217,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, @@ -6414,7 +6417,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 `; @@ -6971,7 +6978,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), diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 58bc33cd0d645..9496f1cf68b85 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; }[], @@ -265,7 +270,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, }, @@ -420,6 +425,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]!; @@ -427,9 +458,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 e91f170689a56..413130f507272 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -500,10 +500,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); @@ -521,7 +527,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;