Skip to content

Commit adb1e9b

Browse files
committed
feat(web): hide subagent threads from user-facing thread lists
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.
1 parent c3a50f8 commit adb1e9b

18 files changed

Lines changed: 260 additions & 63 deletions

apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,13 @@ export function ThreadRelationshipsBanner(props: {
8080
const rows = useMemo(
8181
() =>
8282
copySorted(
83-
immediateThreadRelationships(graph, props.threadId),
83+
// Subagent edges come from the projection, not from thread shells, so
84+
// they survive the shell filtering and would point at threads the
85+
// client no longer receives — rendering as "Unavailable", and taking
86+
// over the banner headline when they sort first.
87+
immediateThreadRelationships(graph, props.threadId).filter(
88+
(relationship) => relationship.edge.kind !== "subagent",
89+
),
8490
(left, right) =>
8591
Number(right.threadId === mergeTargetThreadId) -
8692
Number(left.threadId === mergeTargetThreadId),

apps/mobile/src/features/threads/thread-work-log.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,6 @@ function ThreadActivityThreadLink(props: {
125125
if (item.type === "thread_created") {
126126
targetThreadId = item.targetThreadId;
127127
label = "Open created thread";
128-
} else if (item.type === "subagent") {
129-
targetThreadId = support.subagent?.childThreadId ?? item.childThreadId;
130-
label = "Open subagent thread";
131128
} else if (item.type === "fork") {
132129
targetThreadId =
133130
item.targetThreadId === row.sourceThreadId && item.source.type === "run"

apps/server/src/orchestration-v2/ThreadManagementService.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { expect, it } from "@effect/vitest";
22
import {
33
CommandId,
4+
NodeId,
45
type OrchestrationV2Command,
6+
type OrchestrationV2ThreadShell,
57
ProjectId,
68
ProviderInstanceId,
79
RunId,
810
ThreadId,
911
} from "@t3tools/contracts";
1012

11-
import { withCreationProvenance } from "./ThreadManagementService.ts";
13+
import { userFacingShellSnapshot, withCreationProvenance } from "./ThreadManagementService.ts";
1214

1315
it("stamps authoritative provenance on commands that create threads or messages", () => {
1416
const command: OrchestrationV2Command = {
@@ -55,3 +57,59 @@ it("leaves commands that do not create durable authored content unchanged", () =
5557
}),
5658
).toBe(command);
5759
});
60+
61+
it("removes internal subagent children from active and archived shell collections", () => {
62+
const rootId = ThreadId.make("thread:thread-management:root");
63+
const forkId = ThreadId.make("thread:thread-management:fork");
64+
const lineageSubagentId = ThreadId.make("thread:thread-management:lineage-subagent");
65+
const nodeSubagentId = ThreadId.make("thread:thread-management:node-subagent");
66+
const shell = (
67+
id: ThreadId,
68+
lineage: OrchestrationV2ThreadShell["lineage"],
69+
forkedFrom: OrchestrationV2ThreadShell["forkedFrom"],
70+
) =>
71+
({
72+
id,
73+
lineage,
74+
forkedFrom,
75+
}) as OrchestrationV2ThreadShell;
76+
const rootLineage = {
77+
rootThreadId: rootId,
78+
parentThreadId: null,
79+
relationshipToParent: null,
80+
} as const;
81+
const snapshot = userFacingShellSnapshot({
82+
schemaVersion: 3,
83+
snapshotSequence: 10,
84+
threads: [
85+
shell(rootId, rootLineage, null),
86+
shell(
87+
forkId,
88+
{
89+
rootThreadId: rootId,
90+
parentThreadId: rootId,
91+
relationshipToParent: "fork",
92+
},
93+
{ type: "run", threadId: rootId, runId: RunId.make("run:thread-management:fork") },
94+
),
95+
shell(
96+
lineageSubagentId,
97+
{
98+
rootThreadId: rootId,
99+
parentThreadId: rootId,
100+
relationshipToParent: "subagent",
101+
},
102+
null,
103+
),
104+
],
105+
archivedThreads: [
106+
shell(nodeSubagentId, rootLineage, {
107+
type: "node",
108+
nodeId: NodeId.make("node:thread-management:subagent"),
109+
}),
110+
],
111+
});
112+
113+
expect(snapshot.threads.map((thread) => thread.id)).toEqual([rootId, forkId]);
114+
expect(snapshot.archivedThreads).toEqual([]);
115+
});

apps/server/src/orchestration-v2/ThreadManagementService.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ export class ThreadManagementService extends Context.Service<
171171
ThreadManagementServiceShape
172172
>()("t3/orchestration-v2/ThreadManagementService") {}
173173

174+
export const isInternalSubagentThread = (
175+
thread: Pick<OrchestrationV2ThreadShell, "forkedFrom" | "lineage">,
176+
) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node";
177+
178+
export const userFacingShellSnapshot = (snapshot: OrchestrationV2ThreadShellSnapshot) => ({
179+
...snapshot,
180+
threads: snapshot.threads.filter((thread) => !isInternalSubagentThread(thread)),
181+
archivedThreads: snapshot.archivedThreads.filter((thread) => !isInternalSubagentThread(thread)),
182+
});
183+
174184
export function isActiveRun(run: OrchestrationV2Run): boolean {
175185
return (
176186
run.status === "preparing" ||
@@ -269,10 +279,7 @@ const make = Effect.gen(function* () {
269279
Effect.map((snapshot) =>
270280
snapshot.threads
271281
.filter((thread) => thread.projectId === input.projectId)
272-
.filter(
273-
(thread) =>
274-
input.includeSubagents || thread.lineage.relationshipToParent !== "subagent",
275-
)
282+
.filter((thread) => input.includeSubagents || !isInternalSubagentThread(thread))
276283
.toSorted(
277284
(left, right) =>
278285
DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) ||

apps/server/src/orchestration-v2/http.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
6666
const base = yield* sql.withTransaction(
6767
Effect.gen(function* () {
6868
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
69-
const threads = yield* threadManagement.getShellSnapshot();
69+
const threads = yield* threadManagement
70+
.getShellSnapshot()
71+
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
7072
return {
7173
schemaVersion: threads.schemaVersion,
7274
snapshotSequence: yield* applicationEvents.latestApplicationSequence,

apps/server/src/serverRuntimeStartup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
218218
const shell = yield* threads.getShellSnapshot();
219219
const existingThread = shell.threads.find(
220220
(thread) =>
221-
thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent",
221+
thread.projectId === project.id && !ThreadManagement.isInternalSubagentThread(thread),
222222
);
223223
if (existingThread === undefined) {
224224
const launched = yield* threadLaunch.launch({

apps/server/src/ws.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,9 @@ const makeWsRpcLayer = (
748748
const base = yield* sql.withTransaction(
749749
Effect.gen(function* () {
750750
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
751-
const threads = yield* threadManagement.getShellSnapshot();
751+
const threads = yield* threadManagement
752+
.getShellSnapshot()
753+
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
752754
return {
753755
schemaVersion: threads.schemaVersion,
754756
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
@@ -797,7 +799,12 @@ const makeWsRpcLayer = (
797799
const survivors = coalesceShellApplicationEvents(events);
798800
const needsThreadSnapshot = survivors.some((stored) => !("aggregateKind" in stored));
799801
const threadSnapshot = needsThreadSnapshot
800-
? yield* threadManagement.getShellSnapshot().pipe(Effect.map(Option.some))
802+
? yield* threadManagement
803+
.getShellSnapshot()
804+
.pipe(
805+
Effect.map(ThreadManagementService.userFacingShellSnapshot),
806+
Effect.map(Option.some),
807+
)
801808
: Option.none();
802809
return yield* Effect.forEach(
803810
survivors,
@@ -933,7 +940,9 @@ const makeWsRpcLayer = (
933940
.withTransaction(
934941
Effect.gen(function* () {
935942
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
936-
const threads = yield* threadManagement.getShellSnapshot();
943+
const threads = yield* threadManagement
944+
.getShellSnapshot()
945+
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
937946
return {
938947
schemaVersion: threads.schemaVersion,
939948
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
@@ -966,6 +975,7 @@ const makeWsRpcLayer = (
966975
.pipe(
967976
Stream.mapEffect((stored) =>
968977
threadManagement.getShellSnapshot().pipe(
978+
Effect.map(ThreadManagementService.userFacingShellSnapshot),
969979
Effect.map((nextSnapshot) =>
970980
archivedShellStreamItemFromSnapshot({ stored, snapshot: nextSnapshot }),
971981
),

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ import {
3131
sortProjectsForSidebar,
3232
THREAD_JUMP_HINT_SHOW_DELAY_MS,
3333
} from "./Sidebar.logic";
34-
import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
34+
import {
35+
EnvironmentId,
36+
NodeId,
37+
ProjectId,
38+
ProviderInstanceId,
39+
RunId,
40+
ThreadId,
41+
} from "@t3tools/contracts";
3542
import {
3643
DEFAULT_INTERACTION_MODE,
3744
DEFAULT_RUNTIME_MODE,
@@ -209,6 +216,13 @@ describe("sidebar thread lineage helpers", () => {
209216
});
210217

211218
expect(isSidebarSubagentThread(subagent)).toBe(true);
219+
expect(
220+
isSidebarSubagentThread(
221+
makeThreadFixture({
222+
forkedFrom: { type: "node", nodeId: NodeId.make("node-provider-subagent") },
223+
}),
224+
),
225+
).toBe(true);
212226
expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false);
213227
});
214228

apps/web/src/components/Sidebar.logic.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from "react";
22
import type { ContextMenuItem } from "@t3tools/contracts";
33
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
4+
export { isInternalSubagentThread as isSidebarSubagentThread } from "../threadVisibility";
45
import {
56
getThreadSortTimestamp,
67
sortThreads,
@@ -92,10 +93,6 @@ export function buildMultiSelectThreadContextMenuItems(input: {
9293
];
9394
}
9495

95-
export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
96-
return thread.lineage.relationshipToParent === "subagent";
97-
}
98-
9996
export function getSidebarForkParentThreadId(
10097
thread: Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,
10198
) {

apps/web/src/components/SidebarV2.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import {
104104
formatWorkingDurationLabel,
105105
firstValidTimestampMs,
106106
hasUnseenCompletion,
107+
isSidebarSubagentThread,
107108
isTrailingDoubleClick,
108109
orderItemsByPreferredIds,
109110
resolveAdjacentThreadId,
@@ -1370,6 +1371,7 @@ export default function SidebarV2() {
13701371
const visible = threads.filter(
13711372
(thread) =>
13721373
thread.archivedAt === null &&
1374+
!isSidebarSubagentThread(thread) &&
13731375
(scopedProjectKeys === null ||
13741376
scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
13751377
);

0 commit comments

Comments
 (0)