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..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; @@ -126,9 +120,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/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.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx new file mode 100644 index 00000000000..809fa8cd76d --- /dev/null +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx @@ -0,0 +1,82 @@ +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, + resolveLatestMergeBackRun: () => null, +})); +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 7427d06f4f5..fd82ce789c2 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -107,25 +107,27 @@ 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; - if (relationshipRows.length === 0) { + if (relationshipRows.length === 0 && !canDetach) { return null; } 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);