diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 8292121ac2ce..7a9de4ca746c 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,3 +1,4 @@ +import { threadKey } from "@t3tools/client-runtime/state/entities"; import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -14,20 +15,23 @@ import { CommandPalette } from "./CommandPalette"; const entities = vi.hoisted(() => ({ projects: [] as ReadonlyArray, threads: [] as ReadonlyArray, + delegatedUnderParent: new Set() as ReadonlySet, })); vi.mock("../state/entities", async (original) => ({ ...(await original()), useProjects: () => entities.projects, useVisibleThreadShells: () => entities.threads, + useDelegatedUnderParent: () => entities.delegatedUnderParent, })); -function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { +function setup(state: Partial = {}) { // Earlier browser test files share this origin's localStorage; a remembered project // that is not listed here hides the default "New thread in " row. removeLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY); useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; + entities.delegatedUnderParent = state.delegatedUnderParent ?? new Set(); } describe("CommandPalette open intents", () => { @@ -134,11 +138,13 @@ describe("CommandPalette open intents", () => { await expect.element(input).toHaveValue("acme"); }); - it("opens a searched project's latest listed thread, not a newer subagent", async () => { + it("opens a searched project's latest listed thread, not a newer subagent or delegated task", async () => { const environmentId = EnvironmentId.make("local"); const projectId = ProjectId.make("acme"); const parentId = ThreadId.make("parent"); + const delegatedId = ThreadId.make("delegated"); setup({ + delegatedUnderParent: new Set([threadKey({ environmentId, threadId: delegatedId })]), projects: [ { id: projectId, @@ -170,6 +176,12 @@ describe("CommandPalette open intents", () => { relationshipToParent: "subagent", }, }), + makeThreadFixture({ + id: delegatedId, + environmentId, + projectId, + latestUserMessageAt: "2026-03-04T00:00:00.000Z", + }), ], }); await using screen = await render({null}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b0b8e510de5b..0ea2cd7a2015 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -138,6 +138,7 @@ import { } from "../state/environments"; import { useAnyEnvironmentHiding, + useDelegatedUnderParent, useHiddenThreadKeys, useEnvironmentThreadRefs, useProjects, @@ -145,7 +146,7 @@ import { useVisibleThreadShells, waitForProject, } from "../state/entities"; -import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; +import { isShownUnderParent, visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { resolveThreadActionProjectRef, startNewThreadFromContext, @@ -229,7 +230,6 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent, type PendingInputSelection } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -516,8 +516,16 @@ function OpenCommandPaletteDialog(props: { if (activeThreadReferenceCopyTarget === null) return; await copyThreadReference(activeThreadReferenceCopyTarget); }; - // Side chats and subagents open inside their parent, so opening a project never lands on one. - const threads = useVisibleThreadShells().filter((thread) => !isSidebarHiddenThread(thread)); + // Threads the sidebar shows under their parent open there, so opening a project never lands on one. + const delegatedUnderParent = useDelegatedUnderParent(); + const threads = useVisibleThreadShells().filter( + (thread) => + !isShownUnderParent( + { environmentId: thread.environmentId, threadId: thread.id }, + thread.lineage, + delegatedUnderParent, + ), + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providersByEnvironment = useEnvironmentProviders(); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b89aa1621a0f..9705bf84e73e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,4 +1,5 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { threadKey } from "@t3tools/client-runtime/state/entities"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; @@ -678,6 +679,7 @@ describe("getFallbackThreadIdAfterDelete", () => { deletedThreadIds: new Set([ThreadId.make("thread-active"), ThreadId.make("thread-newest")]), sortOrder: "created_at", now: "2026-03-09T10:10:00.000Z", + delegatedUnderParent: new Set(), }); expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); @@ -688,6 +690,7 @@ describe("getFallbackThreadIdAfterDelete", () => { "snoozed", { snoozedUntil: "2099-01-01T00:00:00.000Z", snoozedAt: "2026-03-09T10:06:00.000Z" }, ], + ["delegated", { id: ThreadId.make("thread-delegated") }], ["settled", { settledOverride: "settled", settledAt: "2026-03-09T10:06:00.000Z" }], [ "side chat", @@ -733,6 +736,12 @@ describe("getFallbackThreadIdAfterDelete", () => { deletedThreadId: ThreadId.make("thread-active"), sortOrder: "created_at", now: "2026-03-09T10:10:00.000Z", + delegatedUnderParent: new Set([ + threadKey({ + environmentId: localEnvironmentId, + threadId: ThreadId.make("thread-delegated"), + }), + ]), }); expect(fallbackThreadId).toBe(ThreadId.make("thread-visible")); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7c96c89d3028..8c0e678ff667 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1319,15 +1319,18 @@ export function getFallbackThreadIdAfterDelete< "id" | "projectId" | "createdAt" | "updatedAt" | "settledOverride" | "lineage" > & ThreadSortInput & - ThreadSnoozeShell, + ThreadSnoozeShell & + Pick, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; sortOrder: SidebarThreadSortOrder; now: string; deletedThreadIds?: ReadonlySet; + delegatedUnderParent: ReadonlySet; }): T["id"] | null { - const { deletedThreadId, deletedThreadIds, sortOrder, now, threads } = input; + const { deletedThreadId, deletedThreadIds, sortOrder, now, threads, delegatedUnderParent } = + input; const deletedThread = threads.find((thread) => thread.id === deletedThreadId); if (!deletedThread) { return null; @@ -1342,7 +1345,11 @@ export function getFallbackThreadIdAfterDelete< !deletedThreadIds?.has(thread.id) && thread.settledOverride !== "settled" && !effectiveSnoozed(thread, { now }) && - !isSidebarHiddenThread(thread), + !isShownUnderParent( + { environmentId: thread.environmentId, threadId: thread.id }, + thread.lineage, + delegatedUnderParent, + ), ), sortOrder, )[0]?.id ?? null diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index d1a74e4e894f..c15dc90bb11f 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -142,6 +142,7 @@ vi.mock("../state/entities", () => ({ .filter((thread) => thread.environmentId === environmentId) .map((thread) => ({ environmentId, threadId: thread.id })), readProject: () => ({ workspaceRoot: "/project" }), + readDelegatedUnderParent: () => new Set(), })); vi.mock("../components/Sidebar.logic", () => ({ getFallbackThreadIdAfterDelete: () => null, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6a8ce92af5e3..9c5ae260923c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -38,6 +38,7 @@ import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { announce } from "../lib/announce"; import { readLocalApi } from "../localApi"; import { + readDelegatedUnderParent, readEnvironmentServerConfigLoaded, readEnvironmentSupportsAutoSettleOptOut, readEnvironmentThreadRefs, @@ -593,6 +594,7 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, now: new Date().toISOString(), + delegatedUnderParent: readDelegatedUnderParent(), }); let navigatedDuringDelete = false; // The missing-thread redirect and the index draft replace this entry; any other history diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 0cb7bbc60093..27168e1ce789 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -385,6 +385,10 @@ export function readThreadShells(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); } +export function readDelegatedUnderParent(): ReadonlySet { + return appAtomRegistry.get(environmentThreadShells.delegatedUnderParentAtom); +} + function shellParentThreadId( thread: Pick, ): ThreadId | null {