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
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/SideChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
import { Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { useComposerDraftStore } from "../../composerDraftStore";
import { ComposerHandleContext } from "../../composerHandleContext";
import { useClientSettings } from "../../hooks/useSettings";
import { releaseComposerDraftUploads } from "../../lib/composerDraftUploads";
import type { SideChatParentPanel } from "../../lib/sideChatPane";
import { readLocalApi } from "../../localApi";
import { hostRightPanel, useRightPanelStore } from "../../rightPanelStore";
Expand Down Expand Up @@ -111,6 +113,8 @@ export default function SideChatPane(props: {
});
setDeleting(false);
if (result._tag === "Success") {
releaseComposerDraftUploads(threadRef);
useComposerDraftStore.getState().clearDraftThread(threadRef);
useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`);
} else if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Expand Down
98 changes: 88 additions & 10 deletions apps/web/src/hooks/useThreadActions.permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,23 @@ function deferred<T>() {
}

const state = vi.hoisted(() => ({
serverConfig: { shellThreadFamilies: false },
familyLookupFails: false,
scopes: new Map<string, Set<string>>(),
threads: [] as {
environmentId: EnvironmentId;
id: ThreadId;
projectId: ProjectId;
title: string;
worktreePath: string | null;
lineage: { parentThreadId: ThreadId | null; sideChat?: boolean };
session: { status: "ready" | "stopped" } | null;
latestTurn: null;
}[],
familyThreads: [] as {
id: ThreadId;
lineage: { parentThreadId: ThreadId | null; sideChat?: boolean };
}[],
requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[],
localEffects: [] as string[],
confirm: vi.fn<(message: string) => Promise<boolean>>(),
Expand Down Expand Up @@ -70,15 +77,30 @@ vi.mock("../state/use-atom-command", () => ({
},
}));
vi.mock("../state/use-atom-query-runner", () => ({
useAtomQueryRunner: () => async (environmentId: string) =>
state.sessionLookupFails
? AsyncResult.failure(Cause.fail(new Error("Session lookup failed")))
: AsyncResult.success({
authenticated: true,
scopes: [...(state.scopes.get(environmentId) ?? [])],
auth: { serverUpdateScope: "environment:maintain" },
}),
useAtomQueryRunner: (family: unknown) => async (environmentId: string) =>
family === "threadFamily"
? state.familyLookupFails
? AsyncResult.failure(Cause.fail(new Error("Family lookup failed")))
: AsyncResult.success({ threads: state.familyThreads })
: state.sessionLookupFails
? AsyncResult.failure(Cause.fail(new Error("Session lookup failed")))
: AsyncResult.success({
authenticated: true,
scopes: [...(state.scopes.get(environmentId) ?? [])],
auth: { serverUpdateScope: "environment:maintain" },
}),
}));
vi.mock("../state/orchestration", () => ({
orchestrationEnvironment: { threadFamily: "threadFamily" },
}));
vi.mock("../state/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("../state/server")>();
const { Atom } = await import("effect/reactivity");
return {
...actual,
environmentServerConfigsAtom: Atom.make(new Map([["secondary", state.serverConfig]])),
};
});
vi.mock("../state/threads", () => ({
threadEnvironment: Object.fromEntries(
[
Expand Down Expand Up @@ -119,7 +141,8 @@ vi.mock("../components/Sidebar.logic", () => ({
vi.mock("../composerDraftStore", () => ({
useComposerDraftStore: (select: (store: unknown) => unknown) =>
select({
clearDraftThread: () => state.localEffects.push("clear-draft"),
clearDraftThread: (ref: ScopedThreadRef) =>
state.localEffects.push(`clear-draft:${ref.threadId}`),
clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"),
}),
}));
Expand All @@ -139,7 +162,8 @@ vi.mock("../lib/archivedThreadsState", () => ({
refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"),
}));
vi.mock("../lib/composerDraftUploads", () => ({
releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"),
releaseComposerDraftUploads: (ref: ScopedThreadRef) =>
state.localEffects.push(`release-uploads:${ref.threadId}`),
}));
vi.mock("../localApi", () => ({
readLocalApi: () => ({ dialogs: { confirm: state.confirm } }),
Expand Down Expand Up @@ -227,6 +251,9 @@ const operations = [
] as const;

beforeEach(() => {
state.serverConfig.shellThreadFamilies = false;
state.familyLookupFails = false;
state.familyThreads = [];
state.scopes = new Map([
[primary, new Set([AuthOrchestrationOperateScope])],
[secondary, new Set<string>()],
Expand All @@ -238,6 +265,7 @@ beforeEach(() => {
projectId: ProjectId.make("project"),
title: "Thread",
worktreePath: null,
lineage: { parentThreadId: null },
session: null,
latestTurn: null,
},
Expand Down Expand Up @@ -308,6 +336,18 @@ describe("thread action permissions", () => {
);

it("deletes an archived thread only with its own environment's grant", async () => {
state.serverConfig.shellThreadFamilies = true;
state.familyThreads = [
{
id: ThreadId.make("side-chat"),
lineage: { parentThreadId: target.threadId, sideChat: true },
},
{ id: ThreadId.make("fork"), lineage: { parentThreadId: target.threadId } },
{
id: ThreadId.make("other-side-chat"),
lineage: { parentThreadId: ThreadId.make("other-parent"), sideChat: true },
},
];
state.threads = [];
seedThreadPanelState(target);
const actions = useThreadActions();
Expand All @@ -320,6 +360,26 @@ describe("thread action permissions", () => {
expect.objectContaining({ action: "delete", environmentId: secondary }),
]);
expect(threadPanelStatePresence(target)).toEqual(panelStateCleared);
expect(state.localEffects).toEqual(
expect.arrayContaining([
"release-uploads:thread",
"clear-draft:thread",
"release-uploads:side-chat",
"clear-draft:side-chat",
]),
);
expect(state.localEffects).not.toContain("clear-draft:fork");
expect(state.localEffects).not.toContain("clear-draft:other-side-chat");

// The family read only finds drafts to clear; it never blocks the delete.
state.familyLookupFails = true;
state.requests = [];
state.localEffects = [];
expect((await actions.deleteThread(target))._tag).toBe("Success");
expect(state.requests).toEqual([
expect.objectContaining({ action: "delete", environmentId: secondary }),
]);
expect(state.localEffects).toContain("clear-draft:thread");
});

it("stops before delete and local cleanup when permission is revoked during session stop", async () => {
Expand Down Expand Up @@ -348,6 +408,24 @@ describe("thread action permissions", () => {
expect(threadPanelStatePresence(target)).toEqual(panelStateCleared);
});

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 },
},
{ ...parent, id: ThreadId.make("fork"), lineage: { parentThreadId: parent.id } },
);
expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
expect(state.localEffects).toEqual(
expect.arrayContaining(["release-uploads:side-chat", "clear-draft:side-chat"]),
);
expect(state.localEffects).not.toContain("clear-draft:fork");
});

it("does not request worktree removal after its grant is revoked during delete", async () => {
state.scopes
.get(secondary)!
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ export function useThreadActions() {
reportFailure: false,
refresh: true,
});
const loadThreadFamily = useAtomQueryRunner(orchestrationEnvironment.threadFamily, {
reportFailure: false,
refresh: true,
});
const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
reportFailure: false,
});
Expand Down Expand Up @@ -440,11 +444,37 @@ export function useThreadActions() {
if (permissionFailure) return permissionFailure;
const resolved = resolveThreadTarget(target);
if (!resolved) {
let threads = readEnvironmentThreadRefs(target.environmentId).flatMap((ref) => {
const shell = readThreadShell(ref);
return shell === null ? [] : [{ id: shell.id, lineage: shell.lineage }];
});
if (
appAtomRegistry.get(environmentServerConfigsAtom).get(target.environmentId)
?.shellThreadFamilies
) {
const family = await loadThreadFamily({
environmentId: target.environmentId,
input: { threadId: target.threadId },
});
if (family._tag === "Success") threads = [...family.value.threads];
Comment thread
saphid marked this conversation as resolved.
Resolved
}
const result = await deleteThreadMutation({
environmentId: target.environmentId,
input: { threadId: target.threadId },
});
if (result._tag === "Success") {
for (const ref of [
target,
...threads
.filter(
(entry) =>
entry.lineage.sideChat === true && entry.lineage.parentThreadId === target.threadId,
)
.map((entry) => scopeThreadRef(target.environmentId, entry.id)),
]) {
releaseComposerDraftUploads(ref);
clearComposerDraftForThread(ref);
}
expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]);
refreshArchivedThreadsForEnvironment(target.environmentId);
clearDeletedThreadPanelState(target);
Expand Down Expand Up @@ -566,6 +596,15 @@ export function useThreadActions() {
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
releaseComposerDraftUploads(threadRef);
clearComposerDraftForThread(threadRef);
// The server deletes the thread's side chats with it.
for (const entry of threads) {
if (entry.lineage.sideChat !== true || entry.lineage.parentThreadId !== threadRef.threadId) {
continue;
}
const sideChatRef = scopeThreadRef(threadRef.environmentId, entry.id);
releaseComposerDraftUploads(sideChatRef);
clearComposerDraftForThread(sideChatRef);
}
clearProjectDraftThreadById(
scopeProjectRef(threadRef.environmentId, thread.projectId),
threadRef,
Expand Down
6 changes: 6 additions & 0 deletions packages/client-runtime/src/state/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
label: "environment-data:orchestration:archived-shell-snapshot",
tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot,
}),
threadFamily: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:orchestration:thread-family-for-delete",
tag: ORCHESTRATION_V2_WS_METHODS.getThreadFamily,
staleTimeMs: 0,
idleTtlMs: 0,
}),
// Read right before deleting a worktree, so never served from cache.
worktreeThreadIds: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:orchestration:worktree-thread-ids",
Expand Down
Loading