Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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({
Expand Down
20 changes: 18 additions & 2 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6215,6 +6217,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
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,
Expand Down Expand Up @@ -6414,7 +6417,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
}${
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
`;
Expand Down Expand Up @@ -6971,7 +6978,16 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
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),
Comment thread
saphid marked this conversation as resolved.
Resolved
);
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),
Expand Down
43 changes: 39 additions & 4 deletions apps/web/src/hooks/useThreadActions.permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}[],
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -420,16 +425,46 @@ 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]!;
state.threads.push(
{
...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(
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThreadId>(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),
Comment thread
saphid marked this conversation as resolved.
Resolved
);
const environmentConfig = appAtomRegistry
.get(environmentServerConfigsAtom)
.get(threadRef.environmentId);
Expand All @@ -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;
Expand Down
Loading