From 7d3763d7ac2fa4891d984606da77882cc0f40a4b Mon Sep 17 00:00:00 2001 From: SAKETH11111 Date: Fri, 10 Apr 2026 15:54:23 -0500 Subject: [PATCH] fix archived-only project deletion --- .../src/provider/Layers/ClaudeAdapter.test.ts | 83 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 47 +++++++++-- apps/web/src/components/Sidebar.logic.test.ts | 50 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 20 +++++ apps/web/src/components/Sidebar.tsx | 25 +++--- 5 files changed, 206 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6ba..e99952b100 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -965,6 +965,89 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("tracks server tool blocks as runtime tool items", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-server-tool", + uuid: "stream-server-tool-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "server_tool_use", + id: "server-tool-1", + name: "Bash", + input: { + command: "pwd", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-server-tool", + uuid: "stream-server-tool-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 0, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-server-tool", + uuid: "result-server-tool", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); + assert.equal(String(toolStarted.turnId), String(turn.turnId)); + } + + const toolCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + assert.equal(toolCompleted?.type, "item.completed"); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d99e2ad203..54751631cc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -909,6 +909,38 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } +type ClaudeToolStartBlock = { + readonly type: "tool_use" | "server_tool_use" | "mcp_tool_use"; + readonly id: string; + readonly name: string; + readonly input?: unknown; +}; + +function getClaudeToolStartBlock(block: unknown): ClaudeToolStartBlock | undefined { + if (typeof block !== "object" || block === null) { + return undefined; + } + + const candidate = block as Record; + if ( + candidate.type !== "tool_use" && + candidate.type !== "server_tool_use" && + candidate.type !== "mcp_tool_use" + ) { + return undefined; + } + if (typeof candidate.id !== "string" || typeof candidate.name !== "string") { + return undefined; + } + + return { + type: candidate.type, + id: candidate.id, + name: candidate.name, + ...(Object.hasOwn(candidate, "input") ? { input: candidate.input } : {}), + }; +} + const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { @@ -1629,21 +1661,18 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; } - if ( - block.type !== "tool_use" && - block.type !== "server_tool_use" && - block.type !== "mcp_tool_use" - ) { + const toolBlock = getClaudeToolStartBlock(block); + if (!toolBlock) { return; } - const toolName = block.name; + const toolName = toolBlock.name; const itemType = classifyToolItemType(toolName); const toolInput = - typeof block.input === "object" && block.input !== null - ? (block.input as Record) + typeof toolBlock.input === "object" && toolBlock.input !== null + ? (toolBlock.input as Record) : {}; - const itemId = block.id; + const itemId = toolBlock.id; const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..2c0c08e978 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, + getSidebarThreadsByIds, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -621,6 +622,55 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("getSidebarThreadsByIds", () => { + it("filters out archived and missing threads by default", () => { + const visibleThread = makeThread({ + id: ThreadId.makeUnsafe("thread-visible"), + archivedAt: null, + }); + const archivedThread = makeThread({ + id: ThreadId.makeUnsafe("thread-archived"), + archivedAt: "2026-03-09T10:11:00.000Z", + }); + + const result = getSidebarThreadsByIds({ + threadIds: [ + ThreadId.makeUnsafe("thread-visible"), + ThreadId.makeUnsafe("thread-missing"), + ThreadId.makeUnsafe("thread-archived"), + ], + threadsById: { + [visibleThread.id]: visibleThread, + [archivedThread.id]: archivedThread, + }, + }); + + expect(result).toEqual([visibleThread]); + }); + + it("can include archived threads for callers that need the full project set", () => { + const visibleThread = makeThread({ + id: ThreadId.makeUnsafe("thread-visible"), + archivedAt: null, + }); + const archivedThread = makeThread({ + id: ThreadId.makeUnsafe("thread-archived"), + archivedAt: "2026-03-09T10:11:00.000Z", + }); + + const result = getSidebarThreadsByIds({ + threadIds: [visibleThread.id, archivedThread.id], + threadsById: { + [visibleThread.id]: visibleThread, + [archivedThread.id]: archivedThread, + }, + includeArchived: true, + }); + + expect(result).toEqual([visibleThread, archivedThread]); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..b91cec7172 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -241,6 +241,26 @@ export function getVisibleSidebarThreadIds( ); } +export function getSidebarThreadsByIds< + TThreadId extends PropertyKey, + TThread extends { archivedAt: string | null }, +>(input: { + threadIds: readonly TThreadId[]; + threadsById: Partial>; + includeArchived?: boolean; +}): TThread[] { + const threads = input.threadIds.flatMap((threadId) => { + const thread = input.threadsById[threadId]; + return thread === undefined ? [] : [thread]; + }); + + if (input.includeArchived) { + return threads; + } + + return threads.filter((thread) => thread.archivedAt === null); +} + export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..621c3dd437 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,6 +110,7 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + getSidebarThreadsByIds, getVisibleSidebarThreadIds, getVisibleThreadsForProject, resolveAdjacentThreadId, @@ -850,10 +851,10 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( - (threadIdsByProjectId[projectId] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), + getSidebarThreadsByIds({ + threadIds: threadIdsByProjectId[projectId] ?? [], + threadsById: sidebarThreadsById, + }), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -1252,8 +1253,11 @@ export default function Sidebar() { } if (clicked !== "delete") return; - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { + const visibleProjectThreads = getSidebarThreadsByIds({ + threadIds: threadIdsByProjectId[projectId] ?? [], + threadsById: sidebarThreadsById, + }); + if (visibleProjectThreads.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", @@ -1292,6 +1296,7 @@ export default function Sidebar() { copyPathToClipboard, getDraftThreadByProjectId, projects, + sidebarThreadsById, threadIdsByProjectId, ], ); @@ -1400,10 +1405,10 @@ export default function Sidebar() { }, }); const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), + getSidebarThreadsByIds({ + threadIds: threadIdsByProjectId[project.id] ?? [], + threadsById: sidebarThreadsById, + }), appSettings.sidebarThreadSortOrder, ); const projectStatus = resolveProjectStatusIndicator(