From 3a95fe05f9519863b04f2aac7a4be6004812ef45 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:21:34 +0530 Subject: [PATCH 1/4] feat(web): hide subagent threads from user-facing thread lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subagent's child thread is an implementation detail of the agent that owns it, not a conversation the user started. Listing them alongside real threads makes the sidebar grow with every delegation. Filtered at the source — the shell snapshot the server serves — so every client sees the same set rather than each re-deriving it. The web client filters again for locally-held state, and the thread route redirects to the parent if a subagent thread is reached directly. Mobile is part of the sweep, not left behind. It still offered "Open subagent thread" and had no redirect to fall back on, so it would have navigated to a thread the client no longer receives. Its relationships banner also needed the same subagent-edge filter web has: those edges are derived from the projection rather than from thread shells, so they survive the shell filtering and would have rendered as "Unavailable" — and, sorting first, could have taken over the banner headline. Trade-off worth stating plainly: a subagent's transcript becomes unreachable. The Agents panel added in the previous PR shows what an agent did and what it returned, but not its full conversation. That is the intended shape here, and the "Open subagent thread" affordances are removed on both clients rather than left as dead ends. --- .../threads/ThreadRelationshipsBanner.tsx | 8 ++- .../src/features/threads/thread-work-log.tsx | 3 - .../ThreadManagementService.test.ts | 58 +++++++++++++++++++ .../ThreadManagementService.ts | 15 +++-- apps/server/src/orchestration-v2/http.ts | 4 +- apps/server/src/serverRuntimeStartup.ts | 2 +- apps/server/src/ws.ts | 16 ++++- apps/web/src/components/Sidebar.logic.test.ts | 16 ++++- apps/web/src/components/Sidebar.logic.ts | 11 ++-- apps/web/src/components/SidebarV2.tsx | 1 + .../components/chat/MessagesTimeline.test.tsx | 6 +- .../chat/ThreadRelationshipsControl.tsx | 32 +++++----- .../src/components/chat/V2ItemInspector.tsx | 6 -- .../src/components/chat/V2LifecycleRow.tsx | 2 +- .../routes/_chat.$environmentId.$threadId.tsx | 35 ++++++++++- apps/web/src/state/entities.ts | 49 ++++++++++------ apps/web/src/threadVisibility.test.ts | 53 +++++++++++++++++ apps/web/src/threadVisibility.ts | 9 +++ 18 files changed, 262 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/threadVisibility.test.ts create mode 100644 apps/web/src/threadVisibility.ts diff --git a/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx index 569093d3c44..fa865e1c4ce 100644 --- a/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx +++ b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx @@ -83,7 +83,13 @@ export function ThreadRelationshipsBanner(props: { const rows = useMemo( () => copySorted( - immediateThreadRelationships(graph, props.threadId), + // Subagent edges come from the projection, not from thread shells, so + // they survive the shell filtering and would point at threads the + // client no longer receives — rendering as "Unavailable", and taking + // over the banner headline when they sort first. + immediateThreadRelationships(graph, props.threadId).filter( + (relationship) => relationship.edge.kind !== "subagent", + ), (left, right) => Number(right.threadId === mergeTargetThreadId) - Number(left.threadId === mergeTargetThreadId), diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 1c80644fcb3..9b11152adc1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -126,9 +126,6 @@ function ThreadActivityThreadLink(props: { if (item.type === "thread_created") { targetThreadId = item.targetThreadId; label = "Open created thread"; - } else if (item.type === "subagent") { - targetThreadId = support.subagent?.childThreadId ?? item.childThreadId; - label = "Open subagent thread"; } else if (item.type === "fork") { targetThreadId = item.targetThreadId === row.sourceThreadId && item.source.type === "run" diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 5e7187d9c33..b12ff89656e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -5,6 +5,7 @@ import { NodeId, type OrchestrationV2Command, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, ProjectId, ProviderInstanceId, RunId, @@ -25,6 +26,7 @@ import { ThreadManagementThreadNotFoundError, ThreadManagementThreadNotInterruptibleError, ThreadManagementThreadNotSendableError, + userFacingShellSnapshot, withCreationProvenance, } from "./ThreadManagementService.ts"; @@ -299,3 +301,59 @@ it.effect("uses thread-not-found only after a projection loads outside the proje expect("cause" in error).toBe(false); }).pipe(Effect.provide(testLayer)); }); + +it("removes internal subagent children from active and archived shell collections", () => { + const rootId = ThreadId.make("thread:thread-management:root"); + const forkId = ThreadId.make("thread:thread-management:fork"); + const lineageSubagentId = ThreadId.make("thread:thread-management:lineage-subagent"); + const nodeSubagentId = ThreadId.make("thread:thread-management:node-subagent"); + const shell = ( + id: ThreadId, + lineage: OrchestrationV2ThreadShell["lineage"], + forkedFrom: OrchestrationV2ThreadShell["forkedFrom"], + ) => + ({ + id, + lineage, + forkedFrom, + }) as OrchestrationV2ThreadShell; + const rootLineage = { + rootThreadId: rootId, + parentThreadId: null, + relationshipToParent: null, + } as const; + const snapshot = userFacingShellSnapshot({ + schemaVersion: 3, + snapshotSequence: 10, + threads: [ + shell(rootId, rootLineage, null), + shell( + forkId, + { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "fork", + }, + { type: "run", threadId: rootId, runId: RunId.make("run:thread-management:fork") }, + ), + shell( + lineageSubagentId, + { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + null, + ), + ], + archivedThreads: [ + shell(nodeSubagentId, rootLineage, { + type: "node", + nodeId: NodeId.make("node:thread-management:subagent"), + }), + ], + }); + + expect(snapshot.threads.map((thread) => thread.id)).toEqual([rootId, forkId]); + expect(snapshot.archivedThreads).toEqual([]); +}); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 0214525d799..736c8db74c7 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -297,6 +297,16 @@ export class ThreadManagementService extends Context.Service< ThreadManagementServiceShape >()("t3/orchestration-v2/ThreadManagementService") {} +export const isInternalSubagentThread = ( + thread: Pick, +) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; + +export const userFacingShellSnapshot = (snapshot: OrchestrationV2ThreadShellSnapshot) => ({ + ...snapshot, + threads: snapshot.threads.filter((thread) => !isInternalSubagentThread(thread)), + archivedThreads: snapshot.archivedThreads.filter((thread) => !isInternalSubagentThread(thread)), +}); + export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || @@ -441,10 +451,7 @@ const make = Effect.gen(function* () { Effect.map((snapshot) => snapshot.threads .filter((thread) => thread.projectId === input.projectId) - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => input.includeSubagents || !isInternalSubagentThread(thread)) .toSorted( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index ef4ebd9fa1d..ca376ba0106 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -66,7 +66,9 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const base = yield* sql.withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index fedb01cf3fb..1e7752bbd47 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -219,7 +219,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const shell = yield* threads.getShellSnapshot(); const existingThread = shell.threads.find( (thread) => - thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", + thread.projectId === project.id && !ThreadManagement.isInternalSubagentThread(thread), ); if (existingThread === undefined) { const launched = yield* threadLaunch.launch({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56f70125a43..a21da540a57 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -704,7 +704,9 @@ const makeWsRpcLayer = ( const base = yield* sql.withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, @@ -753,7 +755,12 @@ const makeWsRpcLayer = ( const survivors = coalesceShellApplicationEvents(events); const needsThreadSnapshot = survivors.some((stored) => !("aggregateKind" in stored)); const threadSnapshot = needsThreadSnapshot - ? yield* threadManagement.getShellSnapshot().pipe(Effect.map(Option.some)) + ? yield* threadManagement + .getShellSnapshot() + .pipe( + Effect.map(ThreadManagementService.userFacingShellSnapshot), + Effect.map(Option.some), + ) : Option.none(); return yield* Effect.forEach( survivors, @@ -889,7 +896,9 @@ const makeWsRpcLayer = ( .withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, @@ -922,6 +931,7 @@ const makeWsRpcLayer = ( .pipe( Stream.mapEffect((stored) => threadManagement.getShellSnapshot().pipe( + Effect.map(ThreadManagementService.userFacingShellSnapshot), Effect.map((nextSnapshot) => archivedShellStreamItemFromSnapshot({ stored, snapshot: nextSnapshot }), ), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a5ab342b663..e5be65c3df8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -33,7 +33,14 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + NodeId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -260,6 +267,13 @@ describe("sidebar thread lineage helpers", () => { }); expect(isSidebarSubagentThread(subagent)).toBe(true); + expect( + isSidebarSubagentThread( + makeThreadFixture({ + forkedFrom: { type: "node", nodeId: NodeId.make("node-provider-subagent") }, + }), + ), + ).toBe(true); expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d7c2fa53413..2e586bee182 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,9 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { isInternalSubagentThread as isSidebarSubagentThread } from "../threadVisibility"; + +export { isSidebarSubagentThread }; import { getThreadSortTimestamp, sortThreads, @@ -92,12 +95,8 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; -} - export function filterSidebarV2VisibleThreads< - T extends Pick & { + T extends Pick & { environmentId: string; projectId: string; }, @@ -847,7 +846,7 @@ export function sortLogicalProjectsForSidebar< export function sortSidebarV2ProjectGroups< TProject extends LogicalSidebarProject, - TThread extends ScopedSidebarThread & Pick, + TThread extends ScopedSidebarThread & Pick, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 7f2d31831e7..b1e3677fd71 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -109,6 +109,7 @@ import { filterSidebarV2VisibleThreads, firstValidTimestampMs, hasUnseenCompletion, + isSidebarSubagentThread, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ba3c263f91b..19a0fc46fc6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -993,7 +993,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-v2-item-type="subagent"'); - expect(markup).toContain('aria-label="Open Package audit"'); + expect(markup).not.toContain('aria-label="Open Package audit"'); expect(markup).toContain("Reading src/index.ts"); expect(markup).not.toContain("Inspect the package"); expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); @@ -1049,7 +1049,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-v2-subagent-result-disclosure="true"'); expect(markup).toContain('data-v2-subagent-result="true"'); expect(markup).toContain('aria-label="Show full result for Isolation report"'); - expect(markup).toContain('aria-label="Open Isolation report"'); + expect(markup).not.toContain('aria-label="Open Isolation report"'); expect(markup).toContain("Tests should be isolated."); expect(markup).toContain("Result: no shared state."); expect(markup).not.toContain("Explain test isolation"); @@ -1102,7 +1102,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-v2-item-type="subagent"'); - expect(markup).toContain('aria-label="Open Package audit"'); + expect(markup).not.toContain('aria-label="Open Package audit"'); expect(markup).toContain("Reading src/index.ts"); expect(markup).not.toContain("Partial streamed answer so far"); expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 7427d06f4f5..3e6862a30cd 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -107,21 +107,23 @@ export function ThreadRelationshipsPanel(props: { const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); - const relationshipRows = immediateThreadRelationships(graph, props.threadId).toSorted( - (left, right) => - relationshipSortKey({ - edge: left.edge, - threadId: left.threadId, - currentThreadId: props.threadId, - mergeTargetThreadId, - }) - - relationshipSortKey({ - edge: right.edge, - threadId: right.threadId, - currentThreadId: props.threadId, - mergeTargetThreadId, - }), - ); + const relationshipRows = immediateThreadRelationships(graph, props.threadId) + .filter((relationship) => relationship.edge.kind !== "subagent") + .toSorted( + (left, right) => + relationshipSortKey({ + edge: left.edge, + threadId: left.threadId, + currentThreadId: props.threadId, + mergeTargetThreadId, + }) - + relationshipSortKey({ + edge: right.edge, + threadId: right.threadId, + currentThreadId: props.threadId, + mergeTargetThreadId, + }), + ); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index f39e977b292..ceea24eb4ef 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -265,12 +265,6 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} - {item.type === "subagent" && item.childThreadId !== null ? ( - - ) : null} - {item.type === "handoff" ? (

diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 7e30c8b7ada..4fdc5ecb2b7 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -200,7 +200,7 @@ export function V2LifecycleRow(props: { title={subagentDisplayTitle(item.title ?? "Subagent")} detail={detail} badge={item.status} - threadId={item.childThreadId} + threadId={null} expandedDetail={finalResult} onOpenThread={props.onOpenThread} /> diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 271f5073968..10c09338184 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,11 +4,16 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteRenderState, +} from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; +import { isInternalSubagentThread } from "../threadVisibility"; function ChatThreadRouteView() { const navigate = useNavigate(); @@ -42,6 +47,10 @@ function ChatThreadRouteView() { }); const serverThreadStarted = threadHasStarted(serverThreadShell); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; + const subagentParentThreadId = + serverThreadShell && isInternalSubagentThread(serverThreadShell) + ? serverThreadShell.lineage.parentThreadId + : null; useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -53,6 +62,24 @@ function ChatThreadRouteView() { } }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); + useEffect(() => { + if (!threadRef || !serverThreadShell || !isInternalSubagentThread(serverThreadShell)) { + return; + } + if (subagentParentThreadId === null) { + void navigate({ to: "/", replace: true }); + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: threadRef.environmentId, + threadId: subagentParentThreadId, + }), + replace: true, + }); + }, [navigate, serverThreadShell, subagentParentThreadId, threadRef]); + useEffect(() => { if (!threadRef || !serverThreadStarted || !draftThread) { return; @@ -60,7 +87,11 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || renderState !== "ready") { + if ( + !threadRef || + renderState !== "ready" || + (serverThreadShell !== null && isInternalSubagentThread(serverThreadShell)) + ) { return null; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 1f6c07b246e..2248bb86d0d 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -1,14 +1,17 @@ import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; import type { EnvironmentProject, EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { isUserFacingThread } from "../threadVisibility"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; import { allEnvironmentShellsBootstrappedAtom } from "./shell"; @@ -25,9 +28,6 @@ const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( Atom.withLabel("web-project-refs:empty"), ); -const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( - Atom.withLabel("web-thread-refs:empty"), -); const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-shell:empty"), ); @@ -63,7 +63,11 @@ export function useProjectRefs(): ReadonlyArray { } export function useThreadRefs(): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadRefsAtom); + const threads = useThreadShells(); + return useMemo( + () => threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + [threads], + ); } export function useEnvironmentProjectRefs( @@ -79,10 +83,17 @@ export function useEnvironmentProjectRefs( export function useEnvironmentThreadRefs( environmentId: EnvironmentId | null, ): ReadonlyArray { - return useAtomValue( - environmentId === null - ? EMPTY_THREAD_REFS_ATOM - : environmentThreadShells.environmentThreadRefsAtom(environmentId), + const threads = useThreadShells(); + return useMemo( + () => + environmentId === null + ? EMPTY_THREAD_REFS + : threads.flatMap((thread) => + thread.environmentId === environmentId + ? [scopeThreadRef(thread.environmentId, thread.id)] + : [], + ), + [environmentId, threads], ); } @@ -95,7 +106,8 @@ export function useServerConfigs(): ReadonlyMap { } export function useThreadShells(): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadShellsAtom); + const threads = useAtomValue(environmentThreadShells.threadShellsAtom); + return useMemo(() => threads.filter(isUserFacingThread), [threads]); } export function useAllEnvironmentShellsBootstrapped(): boolean { @@ -105,7 +117,8 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); + const threads = useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); + return useMemo(() => threads.filter(isUserFacingThread), [threads]); } export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { @@ -204,17 +217,19 @@ export function readEnvironmentSupportsVisitedTracking(environmentId: Environmen export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); + return appAtomRegistry + .get(environmentThreadShells.threadShellsAtom) + .filter((thread) => thread.environmentId === environmentId && isUserFacingThread(thread)) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); } export function readThreadRefs(): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); + return appAtomRegistry + .get(environmentThreadShells.threadShellsAtom) + .filter(isUserFacingThread) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); } export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { - return ( - appAtomRegistry - .get(environmentThreadShells.threadRefsAtom) - .find((ref) => ref.threadId === threadId) ?? null - ); + return readThreadRefs().find((ref) => ref.threadId === threadId) ?? null; } diff --git a/apps/web/src/threadVisibility.test.ts b/apps/web/src/threadVisibility.test.ts new file mode 100644 index 00000000000..8bfad0a7964 --- /dev/null +++ b/apps/web/src/threadVisibility.test.ts @@ -0,0 +1,53 @@ +import { NodeId, RunId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isInternalSubagentThread, isUserFacingThread } from "./threadVisibility"; + +const rootId = ThreadId.make("thread:visibility:root"); +const rootLineage = { + rootThreadId: rootId, + parentThreadId: null, + relationshipToParent: null, +} as const; + +describe("thread visibility", () => { + it("keeps roots and user forks visible", () => { + expect(isUserFacingThread({ lineage: rootLineage, forkedFrom: null })).toBe(true); + expect( + isUserFacingThread({ + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "fork", + }, + forkedFrom: { + type: "run", + threadId: rootId, + runId: RunId.make("run:visibility:fork"), + }, + }), + ).toBe(true); + }); + + it("hides explicit and node-owned subagent children", () => { + expect( + isInternalSubagentThread({ + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + forkedFrom: null, + }), + ).toBe(true); + expect( + isInternalSubagentThread({ + lineage: rootLineage, + forkedFrom: { + type: "node", + nodeId: NodeId.make("node:visibility:subagent"), + }, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/threadVisibility.ts b/apps/web/src/threadVisibility.ts new file mode 100644 index 00000000000..9c71cb4a75e --- /dev/null +++ b/apps/web/src/threadVisibility.ts @@ -0,0 +1,9 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; + +export const isInternalSubagentThread = ( + thread: Pick, +) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; + +export const isUserFacingThread = ( + thread: Pick, +) => !isInternalSubagentThread(thread); From 26f4cab5e7e8ee59589f5616884eb8cb77fb264e Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:55:44 +0530 Subject: [PATCH 2/4] refactor(web,mobile): drop code left dead by hiding subagent threads Removing the "Open subagent thread" affordance left the mobile work log subscribing to item support it no longer reads, and the web sidebar importing a predicate it no longer calls. Both tripped the lint rule the CI Check job enforces. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/features/threads/thread-work-log.tsx | 6 ------ apps/web/src/components/SidebarV2.tsx | 1 - 2 files changed, 7 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 9b11152adc1..2dfdb8b575b 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -10,7 +10,6 @@ import { T3_CODE_BRAND_MARK_SOURCE } from "../../components/brandAssets"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import Animated, { FadeIn } from "react-native-reanimated"; -import { useV2ItemSupport } from "../../state/v2-item-support"; import { ThreadActivityInspector } from "./ThreadActivityInspector"; import { threadWorkLogOverflowNoun } from "./thread-work-log-labels"; @@ -113,11 +112,6 @@ function ThreadActivityThreadLink(props: { readonly iconColor: import("react-native").ColorValue; }) { const row = props.activity.projectedItem; - const support = useV2ItemSupport({ - environmentId: props.environmentId, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - }); const navigation = useNavigation(); const item = row.item; let targetThreadId: ThreadId | null = null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b1e3677fd71..7f2d31831e7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -109,7 +109,6 @@ import { filterSidebarV2VisibleThreads, firstValidTimestampMs, hasUnseenCompletion, - isSidebarSubagentThread, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, From 6f715b6a025efc571a0305760679a2500d19a0c3 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:48:40 +0530 Subject: [PATCH 3/4] fix(web): preserve detachable thread controls Co-Authored-By: Claude --- .../chat/ThreadRelationshipsControl.test.tsx | 81 +++++++++++++++++++ .../chat/ThreadRelationshipsControl.tsx | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx new file mode 100644 index 00000000000..60f77ee0660 --- /dev/null +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx @@ -0,0 +1,81 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + canDetach: false, + relationships: [] as ReadonlyArray, +})); + +vi.mock("@t3tools/client-runtime/state/thread-relationships", () => ({ + deriveThreadRelationshipGraph: () => ({ nodes: new Map() }), + immediateThreadRelationships: () => testState.relationships, + resolveMergeBackTargetThreadId: () => null, +})); +vi.mock("@t3tools/client-runtime/state/thread-workflows", () => ({ + canDetachThreadProviderSession: () => testState.canDetach, +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("../../lib/archivedThreadsState", () => ({ + useArchivedThreadSnapshots: () => ({ snapshots: [] }), +})); +vi.mock("../../state/entities", () => ({ + useThreadProjection: () => ({ projection: { runs: [] } }), + useThreadShells: () => [], +})); +vi.mock("../../state/threads", () => ({ + threadEnvironment: { + mergeBack: Symbol("mergeBack"), + stopSession: Symbol("stopSession"), + }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../ui/menu", () => ({ + Menu: ({ children }: { readonly children: ReactNode }) => <>{children}, + MenuTrigger: ({ children }: { readonly children: ReactNode }) => <>{children}, + MenuPopup: ({ children }: { readonly children: ReactNode }) => <>{children}, + MenuItem: ({ children }: { readonly children: ReactNode }) => <>{children}, +})); + +import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; + +const environmentId = "environment:relationships" as EnvironmentId; +const threadId = "thread:relationships" as ThreadId; +const childThreadId = "thread:relationships:subagent" as ThreadId; + +const renderPanel = () => + renderToStaticMarkup( + , + ); + +describe("ThreadRelationshipsPanel", () => { + beforeEach(() => { + testState.canDetach = false; + testState.relationships = [ + { + threadId: childThreadId, + edge: { + kind: "subagent", + sourceThreadId: threadId, + targetThreadId: childThreadId, + status: "running", + }, + }, + ]; + }); + + it("keeps disconnect controls when hidden subagent edges are the only relationships", () => { + testState.canDetach = true; + + const markup = renderPanel(); + + expect(markup).toContain("Lineage"); + expect(markup).toContain("Disconnect agent session"); + expect(markup).not.toContain("Subagent"); + }); + + it("renders nothing when no visible relationship or detachable session exists", () => { + expect(renderPanel()).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 3e6862a30cd..fd82ce789c2 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -127,7 +127,7 @@ export function ThreadRelationshipsPanel(props: { const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; - if (relationshipRows.length === 0) { + if (relationshipRows.length === 0 && !canDetach) { return null; } From 243073629dca72d0bc349ce340e8cf97c505731e Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:15:08 +0530 Subject: [PATCH 4/4] test(web): update thread workflow mock --- apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx index 60f77ee0660..809fa8cd76d 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx @@ -15,6 +15,7 @@ vi.mock("@t3tools/client-runtime/state/thread-relationships", () => ({ })); vi.mock("@t3tools/client-runtime/state/thread-workflows", () => ({ canDetachThreadProviderSession: () => testState.canDetach, + resolveLatestMergeBackRun: () => null, })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); vi.mock("../../lib/archivedThreadsState", () => ({