Skip to content

Commit 05fe44c

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 31adb36 commit 05fe44c

18 files changed

Lines changed: 266 additions & 65 deletions

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,13 @@ export function ThreadRelationshipsBanner(props: {
8383
const rows = useMemo(
8484
() =>
8585
copySorted(
86-
immediateThreadRelationships(graph, props.threadId),
86+
// Subagent edges come from the projection, not from thread shells, so
87+
// they survive the shell filtering and would point at threads the
88+
// client no longer receives — rendering as "Unavailable", and taking
89+
// over the banner headline when they sort first.
90+
immediateThreadRelationships(graph, props.threadId).filter(
91+
(relationship) => relationship.edge.kind !== "subagent",
92+
),
8793
(left, right) =>
8894
Number(right.threadId === mergeTargetThreadId) -
8995
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: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ import {
33
CommandId,
44
NodeId,
55
type OrchestrationV2Command,
6+
type OrchestrationV2ThreadShell,
67
ProjectId,
78
ProviderInstanceId,
89
RunId,
910
ThreadId,
1011
} from "@t3tools/contracts";
1112

12-
import { existingThreadIdsForCommand, withCreationProvenance } from "./ThreadManagementService.ts";
13+
import {
14+
existingThreadIdsForCommand,
15+
userFacingShellSnapshot,
16+
withCreationProvenance,
17+
} from "./ThreadManagementService.ts";
1318

1419
it("stamps authoritative provenance on commands that create threads or messages", () => {
1520
const command: OrchestrationV2Command = {
@@ -161,3 +166,59 @@ it("identifies every existing thread that must be hydrated before dispatch", ()
161166
}),
162167
).toEqual([parentThreadId, targetThreadId]);
163168
});
169+
170+
it("removes internal subagent children from active and archived shell collections", () => {
171+
const rootId = ThreadId.make("thread:thread-management:root");
172+
const forkId = ThreadId.make("thread:thread-management:fork");
173+
const lineageSubagentId = ThreadId.make("thread:thread-management:lineage-subagent");
174+
const nodeSubagentId = ThreadId.make("thread:thread-management:node-subagent");
175+
const shell = (
176+
id: ThreadId,
177+
lineage: OrchestrationV2ThreadShell["lineage"],
178+
forkedFrom: OrchestrationV2ThreadShell["forkedFrom"],
179+
) =>
180+
({
181+
id,
182+
lineage,
183+
forkedFrom,
184+
}) as OrchestrationV2ThreadShell;
185+
const rootLineage = {
186+
rootThreadId: rootId,
187+
parentThreadId: null,
188+
relationshipToParent: null,
189+
} as const;
190+
const snapshot = userFacingShellSnapshot({
191+
schemaVersion: 3,
192+
snapshotSequence: 10,
193+
threads: [
194+
shell(rootId, rootLineage, null),
195+
shell(
196+
forkId,
197+
{
198+
rootThreadId: rootId,
199+
parentThreadId: rootId,
200+
relationshipToParent: "fork",
201+
},
202+
{ type: "run", threadId: rootId, runId: RunId.make("run:thread-management:fork") },
203+
),
204+
shell(
205+
lineageSubagentId,
206+
{
207+
rootThreadId: rootId,
208+
parentThreadId: rootId,
209+
relationshipToParent: "subagent",
210+
},
211+
null,
212+
),
213+
],
214+
archivedThreads: [
215+
shell(nodeSubagentId, rootLineage, {
216+
type: "node",
217+
nodeId: NodeId.make("node:thread-management:subagent"),
218+
}),
219+
],
220+
});
221+
222+
expect(snapshot.threads.map((thread) => thread.id)).toEqual([rootId, forkId]);
223+
expect(snapshot.archivedThreads).toEqual([]);
224+
});

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

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

207+
export const isInternalSubagentThread = (
208+
thread: Pick<OrchestrationV2ThreadShell, "forkedFrom" | "lineage">,
209+
) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node";
210+
211+
export const userFacingShellSnapshot = (snapshot: OrchestrationV2ThreadShellSnapshot) => ({
212+
...snapshot,
213+
threads: snapshot.threads.filter((thread) => !isInternalSubagentThread(thread)),
214+
archivedThreads: snapshot.archivedThreads.filter((thread) => !isInternalSubagentThread(thread)),
215+
});
216+
207217
export function isActiveRun(run: OrchestrationV2Run): boolean {
208218
return (
209219
run.status === "preparing" ||
@@ -359,10 +369,7 @@ const make = Effect.gen(function* () {
359369
Effect.map((snapshot) =>
360370
snapshot.threads
361371
.filter((thread) => thread.projectId === input.projectId)
362-
.filter(
363-
(thread) =>
364-
input.includeSubagents || thread.lineage.relationshipToParent !== "subagent",
365-
)
372+
.filter((thread) => input.includeSubagents || !isInternalSubagentThread(thread))
366373
.toSorted(
367374
(left, right) =>
368375
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
@@ -219,7 +219,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
219219
const shell = yield* threads.getShellSnapshot();
220220
const existingThread = shell.threads.find(
221221
(thread) =>
222-
thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent",
222+
thread.projectId === project.id && !ThreadManagement.isInternalSubagentThread(thread),
223223
);
224224
if (existingThread === undefined) {
225225
const launched = yield* threadLaunch.launch({

apps/server/src/ws.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,9 @@ const makeWsRpcLayer = (
770770
const base = yield* sql.withTransaction(
771771
Effect.gen(function* () {
772772
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
773-
const threads = yield* threadManagement.getShellSnapshot();
773+
const threads = yield* threadManagement
774+
.getShellSnapshot()
775+
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
774776
return {
775777
schemaVersion: threads.schemaVersion,
776778
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
@@ -819,7 +821,12 @@ const makeWsRpcLayer = (
819821
const survivors = coalesceShellApplicationEvents(events);
820822
const needsThreadSnapshot = survivors.some((stored) => !("aggregateKind" in stored));
821823
const threadSnapshot = needsThreadSnapshot
822-
? yield* threadManagement.getShellSnapshot().pipe(Effect.map(Option.some))
824+
? yield* threadManagement
825+
.getShellSnapshot()
826+
.pipe(
827+
Effect.map(ThreadManagementService.userFacingShellSnapshot),
828+
Effect.map(Option.some),
829+
)
823830
: Option.none();
824831
return yield* Effect.forEach(
825832
survivors,
@@ -955,7 +962,9 @@ const makeWsRpcLayer = (
955962
.withTransaction(
956963
Effect.gen(function* () {
957964
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
958-
const threads = yield* threadManagement.getShellSnapshot();
965+
const threads = yield* threadManagement
966+
.getShellSnapshot()
967+
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
959968
return {
960969
schemaVersion: threads.schemaVersion,
961970
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
@@ -988,6 +997,7 @@ const makeWsRpcLayer = (
988997
.pipe(
989998
Stream.mapEffect((stored) =>
990999
threadManagement.getShellSnapshot().pipe(
1000+
Effect.map(ThreadManagementService.userFacingShellSnapshot),
9911001
Effect.map((nextSnapshot) =>
9921002
archivedShellStreamItemFromSnapshot({ stored, snapshot: nextSnapshot }),
9931003
),

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ import {
3333
sortProjectsForSidebar,
3434
THREAD_JUMP_HINT_SHOW_DELAY_MS,
3535
} from "./Sidebar.logic";
36-
import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
36+
import {
37+
EnvironmentId,
38+
NodeId,
39+
ProjectId,
40+
ProviderInstanceId,
41+
RunId,
42+
ThreadId,
43+
} from "@t3tools/contracts";
3744
import {
3845
DEFAULT_INTERACTION_MODE,
3946
DEFAULT_RUNTIME_MODE,
@@ -260,6 +267,13 @@ describe("sidebar thread lineage helpers", () => {
260267
});
261268

262269
expect(isSidebarSubagentThread(subagent)).toBe(true);
270+
expect(
271+
isSidebarSubagentThread(
272+
makeThreadFixture({
273+
forkedFrom: { type: "node", nodeId: NodeId.make("node-provider-subagent") },
274+
}),
275+
),
276+
).toBe(true);
263277
expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false);
264278
});
265279

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as React from "react";
22
import type { ContextMenuItem } from "@t3tools/contracts";
33
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
4+
import { isInternalSubagentThread as isSidebarSubagentThread } from "../threadVisibility";
5+
6+
export { isSidebarSubagentThread };
47
import {
58
getThreadSortTimestamp,
69
sortThreads,
@@ -92,12 +95,8 @@ export function buildMultiSelectThreadContextMenuItems(input: {
9295
];
9396
}
9497

95-
export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
96-
return thread.lineage.relationshipToParent === "subagent";
97-
}
98-
9998
export function filterSidebarV2VisibleThreads<
100-
T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & {
99+
T extends Pick<SidebarThreadSummary, "archivedAt" | "forkedFrom" | "lineage"> & {
101100
environmentId: string;
102101
projectId: string;
103102
},
@@ -832,7 +831,7 @@ export function sortLogicalProjectsForSidebar<
832831

833832
export function sortSidebarV2ProjectGroups<
834833
TProject extends LogicalSidebarProject,
835-
TThread extends ScopedSidebarThread & Pick<SidebarThreadSummary, "lineage">,
834+
TThread extends ScopedSidebarThread & Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,
836835
>(
837836
projects: readonly TProject[],
838837
threads: readonly TThread[],

apps/web/src/components/SidebarV2.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
filterSidebarV2VisibleThreads,
110110
firstValidTimestampMs,
111111
hasUnseenCompletion,
112+
isSidebarSubagentThread,
112113
isTrailingDoubleClick,
113114
orderItemsByPreferredIds,
114115
resolveAdjacentThreadId,

0 commit comments

Comments
 (0)