From 78a07c7c41844515ae1b021aa4ccbcf29085c3cf Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:26:10 -0800 Subject: [PATCH 1/2] test(web): opening a project or deleting the open thread skips delegated tasks The command palette's project pick and the delete fallback should never open a task a linked environment delegated here while the sidebar shows it under its parent. Both tests fail on main, which opens the delegated task because it is the newest thread in the project. --- .../components/CommandPalette.browser.test.tsx | 16 ++++++++++++++-- apps/web/src/components/Sidebar.logic.test.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 9faeab83c6ffc..cba4de42704bb 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 } from "vite-plus/test/browser"; @@ -12,17 +13,20 @@ 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 = {}) { useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; + entities.delegatedUnderParent = state.delegatedUnderParent ?? new Set(); } describe("CommandPalette open intents", () => { @@ -46,11 +50,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, @@ -82,6 +88,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/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3e2e8be2ce18d..82325c4eecb86 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"; @@ -625,6 +626,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")); @@ -635,6 +637,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" }], ] as const)("skips a %s thread parked on its shelf", (_state, parked) => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ @@ -659,6 +662,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")); From dce1a9de7c8ef652fed6ec43535eb38b79d95733 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:26:46 -0800 Subject: [PATCH 2/2] fix(web): opening a project or deleting the open thread skips delegated tasks When a linked environment runs a task for a thread this client lists, the sidebar shows that task under its parent, not as a row of its own (isShownUnderParent with the delegatedUnderParent set). Two picks that open "the project's latest thread" ignored that rule. The command palette's project pick filtered only side chats and subagents, and the delete fallback filtered neither. Right after a delegation, the task is the project's newest thread, because its prompt comes after the parent's last message, so both picks opened the task on its own. The palette now filters its threads with isShownUnderParent, the sidebar's own rule. getFallbackThreadIdAfterDelete takes the delegatedUnderParent set and skips the tasks in it, and useThreadActions reads that set from the registry when it deletes. Bug: two web "pick a thread to open" paths can land on a task one linked environment delegated to another --- apps/web/src/components/CommandPalette.tsx | 16 ++++++++++++---- apps/web/src/components/Sidebar.logic.ts | 11 +++++++++-- .../hooks/useThreadActions.permissions.test.ts | 1 + apps/web/src/hooks/useThreadActions.ts | 2 ++ apps/web/src/state/entities.ts | 4 ++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f14a483d5332a..090e11ef9e258 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -131,6 +131,7 @@ import { } from "../state/environments"; import { useAnyEnvironmentHiding, + useDelegatedUnderParent, useHiddenThreadKeys, useEnvironmentThreadRefs, useProjects, @@ -138,7 +139,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 } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -213,7 +214,6 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -496,8 +496,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.ts b/apps/web/src/components/Sidebar.logic.ts index c20789e2a9160..a38783b67d208 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { threadKey } from "@t3tools/client-runtime/state/entities"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; @@ -1213,15 +1214,18 @@ export function resolveThreadStatusPill(input: { export function getFallbackThreadIdAfterDelete< T extends Pick & 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; @@ -1234,6 +1238,9 @@ export function getFallbackThreadIdAfterDelete< thread.projectId === deletedThread.projectId && thread.id !== deletedThreadId && !deletedThreadIds?.has(thread.id) && + !delegatedUnderParent.has( + threadKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) && thread.settledOverride !== "settled" && !effectiveSnoozed(thread, { now }), ), diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index de5056c4af696..fee57a19ebe46 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -111,6 +111,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 904b94ebfc5c5..31820ff7384b6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -37,6 +37,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { + readDelegatedUnderParent, readEnvironmentServerConfigLoaded, readEnvironmentSupportsAutoSettleOptOut, readEnvironmentThreadRefs, @@ -555,6 +556,7 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, now: new Date().toISOString(), + delegatedUnderParent: readDelegatedUnderParent(), }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..624ccfa737846 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -374,6 +374,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 {