From 90de6ebf5742e33548c4d07b4d981db996fbf37a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 11 Nov 2026 00:34:21 -0800 Subject: [PATCH 1/6] feat(server): pop a finished delegated child out to top level A delegate_task child that turned into work of its own stayed tied to its parent: no sidebar row, its turns reported back to the parent, and the parent's Stop still reached it. A new `thread.detach` command turns such a child into an ordinary top-level thread in place. It keeps its id, history, provider session, model and folder. Its lineage drops the parent and records it in the new optional `lineage.detachedFrom`; `rootThreadId` is unchanged, so the thread stays in its family's lineage reads and threads nested under it need no rewrite. - Allowed only once the child is idle and every task it was given has ended with its result delivered, acknowledged or disposed, so no in-flight result needs rerouting. Provider-native subagents are refused. - The command takes the parent's lock before the child's, like follow-up intake, so it cannot race a follow-up task. - The parent's Stop walk and its task cancel skip a popped-out child. - The shell stream stops hiding its transcript events, and its earlier messages join thread search. - Agents get `pop_out` on t3_thread_organize, but not for their own thread, so a child cannot leave its parent's control on its own. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 13 ++ apps/server/src/mcp/toolkits/core.test.ts | 46 +++++ .../src/mcp/toolkits/thread/handlers.ts | 10 + apps/server/src/mcp/toolkits/thread/tools.ts | 3 +- .../DelegatedCompletionDelivery.test.ts | 187 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 91 ++++++++- .../src/orchestration-v2/ProjectionStore.ts | 13 +- .../src/orchestration-v2/ShellStream.test.ts | 10 + .../src/orchestration-v2/ShellStream.ts | 4 + .../ThreadManagementService.ts | 6 +- .../src/orchestration-v2/ThreadSearch.test.ts | 22 +++ .../src/orchestration-v2/ThreadStop.test.ts | 35 ++++ .../orchestration-v2/messageSearchIndex.ts | 25 +++ .../testkit/OrchestratorScenario.ts | 1 + .../src/provider/agentContextBudget.json | 2 +- apps/server/src/relay/AgentAwarenessRelay.ts | 1 + apps/server/src/telemetry/FeatureUsage.ts | 1 + .../src/state/orchestrationV2Projection.ts | 1 + packages/contracts/src/orchestrationV2.ts | 41 ++++ 19 files changed, 504 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 401b91f24e0c8..019804d427d4e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2587,6 +2587,19 @@ const make = Effect.gen(function* () { } return yield* remoteDelegation.cancel(scope, remoteTask, input); } + // Pop-out needs every task in the child to have ended, and it leaves this thread's control. + const childShell = yield* threadManagement + .getThreadShell(childThreadId) + .pipe(Effect.mapError(threadManagementFailure)); + if ( + childShell?.lineage.detachedFrom?.threadId === scope.thread.threadId && + isTerminalTaskStatus(current.status) + ) { + return { + taskId: input.taskId, + status: current.status, + } satisfies OrchestratorMcpTaskCancelResult; + } const key = yield* requestKey(input.clientRequestId); const parentProjection = yield* loadThreadRecords(scope.thread.threadId, []); // Cancelling stops the child and every task under it, each a write to a diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index fd07107b08bc6..e95637ed8c412 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -421,6 +421,52 @@ it.effect("rejects an unknown organize target instead of archiving the calling t ); }); +it.effect("refuses to let an agent pop its own thread out, but not another thread", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const otherThreadId = ThreadId.make("mcp-core-child"); + for (const target of [undefined, threadId, otherThreadId]) { + const result = yield* server + .callTool({ + name: "t3_thread_organize", + arguments: { action: "pop_out", ...(target === undefined ? {} : { threadId: target }) }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + if (target === otherThreadId) expect(result.isError).not.toBe(true); + else + expect(declaredFailure(result)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("cannot pop itself out"), + }); + } + expect(dispatched).toMatchObject([{ type: "thread.detach", threadId: otherThreadId }]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: ({ threadId: id }) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), + ), + dispatch: (command) => { + dispatched.push(command); + return Effect.succeed({ sequence: 1 } as never); + }, + }), + ), + ), + ), + ); +}); + it.effect("keeps unexpected handler defects private through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 32e45b5d0a1c0..824eb54155f38 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -359,6 +359,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { case "mark_unread": command = { ...common, type: "thread.mark-unread" }; break; + case "pop_out": + // Otherwise a child could leave its parent's Stop on its own. + if (caller?.id === projection.thread.id) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A thread cannot pop itself out; its parent or the user can.", + }); + } + command = { ...common, type: "thread.detach" }; + break; default: command = { ...common, type: `thread.${input.action}` }; } diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index c6670c4e953d3..ce0029322228d 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -31,7 +31,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.", + "Pin, snooze, settle, archive, or mark a thread unread, or `pop_out` a finished delegated child to top level. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), @@ -45,6 +45,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "archive", "unarchive", "mark_unread", + "pop_out", ]), snoozedUntil: Schema.optional(IsoDateTime), }), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index a5c123157bae1..cf673943041ef 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -16,6 +16,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + isListedThreadLineage, RuntimeRequestId, ThreadId, TurnItemId, @@ -1286,6 +1287,14 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { ); }); +const refusalOf = (dispatch: Effect.Effect) => + dispatch.pipe( + Effect.flip, + Effect.map((error) => + error._tag === "OrchestratorDispatchError" ? String(error.cause) : error._tag, + ), + ); + const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); const parentProviderThreadId = (threadId: ThreadId) => @@ -2366,6 +2375,184 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }), ); + it.effect("pops a reported child out in place, and its later turns stay its own", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("thread:pop-out-parent"); + const projectId = ProjectId.make("project:pop-out"); + const parentRunId = RunId.make("run:pop-out-parent"); + const rootNodeId = NodeId.make("node:pop-out-parent"); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make("node:pop-out-seed"), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: "pop-out-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + const popOut = (name: string) => + orchestrator.dispatch({ + type: "thread.detach", + commandId: CommandId.make(`command:pop-out:${name}`), + threadId: child.childThreadId, + }); + assert.include( + yield* refusalOf(popOut("unreported")), + "has a delegated task whose result has not reached its parent", + ); + yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("command:pop-out:acknowledge"), + parentThreadId, + taskId: child.taskId, + observedByRunId: parentRunId, + }); + yield* popOut("reported"); + + const popped = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepStrictEqual(popped.thread.lineage, { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: parentThreadId, + detachedFrom: { threadId: parentThreadId }, + }); + assert.deepStrictEqual(yield* orchestrator.listAppOwnedChildThreadIds(parentThreadId), []); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (row) => row.id === child.childThreadId, + ); + assert.isTrue(shell !== undefined && isListedThreadLineage(shell.lineage)); + + // The old parent's agent can still message it, but that starts no task of its own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:pop-out:followup"), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: MessageId.make("message:pop-out:followup"), + text: "Keep going on your own.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const followup = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs.find( + (run) => run.ordinal === 2, + ); + assert.isDefined(followup); + assert.isUndefined(followup?.delegatedTaskId); + const tasks = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter( + (task) => task.childThreadId === child.childThreadId, + ); + assert.deepStrictEqual( + tasks.map((task) => [task.id, task.status, task.completionDelivery?.state]), + [[child.taskId, "completed", "acknowledged"]], + ); + }), + ); + + it.effect( + "refuses to pop out a working child, a provider's subagent, or a top-level thread", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("thread:pop-out-refused-parent"); + const projectId = ProjectId.make("project:pop-out-refused"); + const parentRunId = RunId.make("run:pop-out-refused-parent"); + const rootNodeId = NodeId.make("node:pop-out-refused-parent"); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make("node:pop-out-refused-seed"), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: "pop-out-refused-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("command:pop-out-refused:acknowledge"), + parentThreadId, + taskId: child.taskId, + observedByRunId: parentRunId, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:pop-out-refused:followup"), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: MessageId.make("message:pop-out-refused:followup"), + text: "One more thing.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const providerChildThreadId = ThreadId.make("thread:pop-out-refused-native"); + const created = (yield* orchestrator.getThreadProjection(child.childThreadId)).thread; + yield* sink.write({ + events: [ + { + id: EventId.make("event:pop-out-refused-native"), + type: "thread.created", + threadId: providerChildThreadId, + occurredAt: now, + payload: { + ...created, + id: providerChildThreadId, + creationSource: "provider", + forkedFrom: null, + }, + }, + ], + }); + const refusals = []; + for (const threadId of [child.childThreadId, providerChildThreadId, parentThreadId]) { + refusals.push( + yield* refusalOf( + orchestrator.dispatch({ + type: "thread.detach", + commandId: CommandId.make(`command:pop-out-refused:${threadId}`), + threadId, + }), + ), + ); + } + assert.deepStrictEqual(refusals, [ + `Thread ${child.childThreadId} is still working. Wait for it to finish or stop it first.`, + `Thread ${providerChildThreadId} is run by its provider's own subagent tool and cannot be popped out.`, + `Thread ${parentThreadId} is not a delegated child thread.`, + ]); + }), + ); + it.effect("delivers the run a background command's wake starts after the original result", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 07c1a544798be..ebd49f3430fc8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -26,6 +26,8 @@ import { type GuestMessageId, isCompactionCommandMessage, isOrchestrationV2WorkActive, + isDelegatedChildThread, + isDelegatedTaskReported, isProviderNativeSubagentThread, MessageId, type NodeId, @@ -569,6 +571,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.detach": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": @@ -4643,6 +4646,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + // Only finished, reported work may leave: an in-flight task's result would have nowhere to go. + const dispatchThreadDetach = Effect.fn("orchestrationV2.dispatch.threadDetach")(function* ( + command: Extract, + events: Ref.Ref>, + ) { + const refuse = (cause: string) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} ${cause}`, + }); + const child = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"]).pipe( + mapDispatchError(command), + ); + const thread = child.thread; + if (thread.deletedAt !== null) return yield* refuse("is deleted."); + if (thread.archivedAt !== null) return yield* refuse("is archived."); + if (isProviderNativeSubagentThread(thread)) { + return yield* refuse("is run by its provider's own subagent tool and cannot be popped out."); + } + const parentThreadId = thread.lineage.parentThreadId; + if (!isDelegatedChildThread(thread) || parentThreadId === null) { + return yield* refuse("is not a delegated child thread."); + } + if (threadHasUnfinishedWork(child)) { + return yield* refuse("is still working. Wait for it to finish or stop it first."); + } + const tasks = (yield* projectionStore + .getThreadRecords(parentThreadId, ["subagents"]) + .pipe(mapDispatchError(command))).subagents.filter( + (task) => task.origin === "app_owned" && task.childThreadId === thread.id, + ); + const spawningRunIds = tasks.flatMap((task) => + task.completionDelivery === undefined && task.runId !== null ? [task.runId] : [], + ); + const liveSpawningRunIds = new Set( + spawningRunIds.length === 0 + ? [] + : (yield* projectionStore + .getThreadRecords(parentThreadId, ["runs"], { runIds: spawningRunIds }) + .pipe(mapDispatchError(command))).runs + .filter(isLiveRun) + .map((run) => run.id), + ); + if ( + tasks.some( + (task) => + !isDelegatedTaskReported(task, task.runId !== null && liveSpawningRunIds.has(task.runId)), + ) + ) { + return yield* refuse( + "has a delegated task whose result has not reached its parent. Wait for it to finish or stop it first.", + ); + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "thread.detached", + threadId: thread.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { + ...thread, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: thread.lineage.rootThreadId, + detachedFrom: { threadId: parentThreadId }, + }, + updatedAt: now, + }, + }); + }); + const dispatchProviderSessionDetach = Effect.fn("orchestrationV2.dispatch.providerSessionDetach")( function* ( command: Extract, @@ -12717,8 +12796,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); /** - * Thread lineage and fork origin are immutable, so this - * is safe to read without holding either thread's dispatch lock. + * Safe to read without holding either thread's dispatch lock: fork origin is immutable and + * lineage only ever leaves a parent (pop-out), so a stale read names the parent the child had, + * and work under that parent's lock re-reads the child's lineage. */ const appOwnedSubagentParentThreadId = Effect.fn("Orchestrator.appOwnedSubagentParentThreadId")( function* (childThreadId: ThreadId) { @@ -13653,6 +13733,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.guest-message.dismiss": yield* dispatchGuestShare(command, events, effects); break; + case "thread.detach": + yield* dispatchThreadDetach(command, events); + break; case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; @@ -14228,9 +14311,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ); - // Answering a message-mode question can also start a follow-up. + // Answering a message-mode question can also start a follow-up, and a pop-out must not + // race the parent writing a follow-up task for the child. if ( command.type !== "runtime-request.respond" && + command.type !== "thread.detach" && (command.type !== "message.dispatch" || !mayStartSubagentFollowup(command)) ) return yield* dispatch; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index e0f6ba249f293..576b7be15c44b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -96,7 +96,7 @@ import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; -import { readableSearchText } from "./messageSearchIndex.ts"; +import { indexThreadMessages, readableSearchText } from "./messageSearchIndex.ts"; import { isThreadHistoryUserTurn, isConversationHistoryItem, @@ -854,6 +854,7 @@ export function applyToProjection( case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": + case "thread.detached": case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": @@ -2061,6 +2062,7 @@ export const layer: Layer.Layer = case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": + case "thread.detached": case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": @@ -2124,6 +2126,7 @@ export const layer: Layer.Layer = deleted_at = excluded.deleted_at, payload_json = excluded.payload_json `; + if (event.type === "thread.detached") yield* indexThreadMessages(sql, event.threadId); break; } case "run.created": @@ -2878,6 +2881,7 @@ export const layer: Layer.Layer = event.type !== "thread.unsettled" && event.type !== "thread.snoozed" && event.type !== "thread.unsnoozed" && + event.type !== "thread.detached" && event.type !== "thread.auto-settle-set" && event.type !== "thread.pinned" && event.type !== "thread.unpinned" && @@ -5659,8 +5663,13 @@ export const layer: Layer.Layer = threadId, ) => sql<{ readonly child_thread_id: string }>`SELECT child_thread_id - FROM orchestration_v2_projection_subagents + FROM orchestration_v2_projection_subagents AS task WHERE thread_id = ${threadId} AND origin = 'app_owned' AND child_thread_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_threads AS child + WHERE child.thread_id = task.child_thread_id + AND json_extract(child.payload_json, '$.lineage.detachedFrom.threadId') = ${threadId} + ) ORDER BY COALESCE(started_at, ''), subagent_id ASC`.pipe( Effect.map((rows) => [...new Set(rows.map((row) => ThreadId.make(row.child_thread_id)))]), Effect.mapError(controlReadError(threadId)), diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 336f9b12f96bf..0269f0f96c4d8 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -78,6 +78,16 @@ describe("subagent shell events", () => { ).toBe(true); expect(subagents.has("new-child")).toBe(true); }); + + it("publishes a popped-out child's transcript once it leaves its parent", () => { + const subagents = new Set(["child"]); + expect(shouldPublishShellApplicationEvent(stored("child", "thread.detached"), subagents)).toBe( + true, + ); + expect( + shouldPublishShellApplicationEvent(stored("child", "turn-item.updated"), subagents), + ).toBe(true); + }); }); it.effect("coalesces a slow shell subscriber by identity and location in sequence order", () => diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 0fa5251a7ef73..c7ef9b0af5f50 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -424,6 +424,10 @@ export function shouldPublishShellApplicationEvent( } return true; } + if (event.type === "thread.detached") { + subagentThreadIds.delete(event.threadId); + return true; + } if (event.type === "thread.deleted") { // Replay and the live tail overlap; keep known IDs so late events stay filtered. return true; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f67e9768667ef..870ce5b8802e8 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1483,6 +1483,10 @@ const make = Effect.gen(function* () { "ThreadManagementService.stopDelegatedTasks", )(function* (input) { const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); + // A child popped out to top level no longer answers to this parent's Stop. + const attachedChildThreadIds = new Set( + yield* orchestrator.listAppOwnedChildThreadIds(input.threadId), + ); const failures: Array< Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError > = []; @@ -1526,7 +1530,7 @@ const make = Effect.gen(function* () { } const threadId = task.childThreadId; // Follow-up tasks reuse a child thread; walk its subtree only once. - if (stoppedChildThreadIds.has(threadId)) continue; + if (stoppedChildThreadIds.has(threadId) || !attachedChildThreadIds.has(threadId)) continue; stoppedChildThreadIds.add(threadId); yield* dispatch({ type: "thread.stop", diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 421e79f5a0904..0c74fbe3f7db6 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -512,6 +512,28 @@ it.layer(layerTest)("ThreadSearch", (it) => { assert.deepEqual((yield* search.search({ query: "ab" })).matches, []); assert.lengthOf((yield* search.search({ query: "renam be" })).matches, 1); assert.lengthOf((yield* search.search({ query: "renam ab" })).matches, 0); + + // Popping the subagent out lists it, along with what it wrote before. + const created = thread(subagent, project, { subagentOf: parent }); + if (created.type !== "thread.created") throw new Error("expected thread.created"); + yield* projections.apply({ + ...created, + id: EventId.make(`detached:${subagent}`), + type: "thread.detached", + payload: { + ...created.payload, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: parent, + detachedFrom: { threadId: parent }, + }, + }, + }); + assert.deepEqual( + (yield* search.search({ query: "search servi" })).matches.map((match) => match.threadId), + [parent, subagent], + ); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index a0e6d4bae4c86..810591514739c 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -607,6 +607,41 @@ it.effect.each(["run.interrupt", "thread.stop"] as const)( }).pipe(Effect.provide(layerTest)), ); +it.effect("a parent's Stop no longer reaches a child popped out of it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; + const parentThreadId = ThreadId.make("thread:stop-popped-parent"); + yield* createWatchingThread(parentThreadId, 1); + yield* send(parentThreadId, "first", "start_immediately"); + const childThreadId = yield* delegate(parentThreadId, "popped child"); + const child = (yield* orchestrator.getThreadProjection(childThreadId)).thread; + const before = yield* threadState(childThreadId); + yield* projections.apply({ + id: EventId.make("event:stop-popped:detached"), + type: "thread.detached", + threadId: childThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...child, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: child.lineage.rootThreadId, + detachedFrom: { threadId: parentThreadId }, + }, + }, + }); + + yield* threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("stop-popped-parent"), + }); + assert.deepEqual(yield* threadState(childThreadId), before); + }).pipe(Effect.provide(layerTest)), +); + it.effect("a repeated Stop that changes nothing still stops the delegated tasks", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts index 87d7256b6809d..9d0d047796c77 100644 --- a/apps/server/src/orchestration-v2/messageSearchIndex.ts +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -147,6 +147,31 @@ export const recreateMessageSearchTriggers = Effect.fn("MessageSearchIndex.recre }, ); +/** Indexes a thread's messages that `indexedMessageCondition` skipped while it was a subagent. */ +export const indexThreadMessages = Effect.fn("MessageSearchIndex.indexThread")(function* ( + sql: SqlClient.SqlClient, + threadId: string, +) { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + const text = yield* indexedText(sql); + yield* sql` + DELETE FROM orchestration_v2_message_search WHERE rowid IN ( + SELECT rowid FROM orchestration_v2_projection_messages WHERE thread_id = ${threadId} + ) + `; + yield* sql` + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT messages.rowid, ${text("messages")} + FROM orchestration_v2_projection_messages AS messages + WHERE messages.thread_id = ${threadId} + AND ${indexedMessageCondition(sql)} + AND json_extract(messages.payload_json, '$.text') IS NOT NULL + `; +}); + /** * Refills the search index from the messages table. The index is keyed by * message rowid, so anything that may renumber or copy rows (VACUUM, a dev diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b413c0aefa259..45834d0f0d4f0 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -147,6 +147,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray ({ feature: "thread.archive" }), "thread.snooze": () => ({ feature: "thread.snooze" }), "thread.pin": () => ({ feature: "thread.pin" }), + "thread.detach": () => ({ feature: "thread.detach" }), "thread.settle": () => ({ feature: "thread.settle" }), "thread.pull-request.link": () => ({ feature: "thread.pull_request.link" }), "thread.pull-request.watch": (command) => diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index d9368ef4058a4..a6e00f72b95d3 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -280,6 +280,7 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": + case "thread.detached": case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b1fd02b3d3a07..314623f6840c3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -156,6 +156,8 @@ export const OrchestrationV2AppThreadLineage = Schema.Struct({ relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), rootThreadId: ThreadId, sideChat: Schema.optional(Schema.Boolean), + /** The parent a delegated child was popped out of; it keeps the parent's `rootThreadId`. */ + detachedFrom: Schema.optional(Schema.Struct({ threadId: ThreadId })), }); export type OrchestrationV2AppThreadLineage = typeof OrchestrationV2AppThreadLineage.Type; @@ -532,6 +534,38 @@ export function isProviderNativeSubagentThread( return thread.lineage.relationshipToParent === "subagent" && thread.creationSource === "provider"; } +/** A T3 delegate_task child still under its parent, the kind that can be popped out to top level. */ +export function isDelegatedChildThread( + thread: Pick, +): boolean { + return ( + thread.lineage.relationshipToParent === "subagent" && + thread.lineage.parentThreadId !== null && + thread.creationSource !== "provider" + ); +} + +/** + * Whether a delegated task ended and its result reached the parent. A task with no delivery + * record leaves its result to the turn that spawned it, so it counts once that turn has ended. + */ +export function isDelegatedTaskReported( + task: Pick, + spawningRunLive: boolean, +): boolean { + if ( + task.status !== "completed" && + task.status !== "failed" && + task.status !== "cancelled" && + task.status !== "interrupted" + ) + return false; + const delivery = task.completionDelivery?.state; + return delivery === undefined + ? !spawningRunLive + : delivery === "acknowledged" || delivery === "delivered" || delivery === "disposed"; +} + export const IDLE_COMPACTION_TEXT = "/compact"; interface CompactionMessageFields { @@ -1906,6 +1940,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.unsettled", "thread.snoozed", "thread.unsnoozed", + "thread.detached", "thread.pinned", "thread.auto-settle-set", "thread.unpinned", @@ -2800,6 +2835,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.unsettled", "thread.snoozed", "thread.unsnoozed", + "thread.detached", "thread.pinned", "thread.auto-settle-set", "thread.unpinned", @@ -3037,6 +3073,11 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, enabled: Schema.Boolean, }), + Schema.Struct({ + type: Schema.Literal("thread.detach"), + commandId: CommandId, + threadId: ThreadId, + }), Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, From ceda750e6f9d923cfff4bcb54b5acb175db188c7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 11 Nov 2026 00:34:32 -0800 Subject: [PATCH 2/6] feat(web): pop out a delegated child from its menu or the parent's lineage The server can now turn a finished delegated child into a top-level thread; the web client could not ask it to. - The thread menu offers "Pop out to top level" on a delegated child, and the child's row in the parent's Lineage panel offers the same action. Both are disabled with "Wait for it to finish or stop it first" while the child is working or its result has not reached the parent. - Lineage shows a popped-out thread to its former parent as "Popped out", and the parent to it as "Former parent", instead of dropping it. - Client command `popOutThread` (`threadEnvironment.popOut`) in client-runtime. - User docs describe the action. Co-Authored-By: Claude Opus 5.5 --- .../chat/ThreadRelationshipsControl.tsx | 125 ++++++++++++++---- .../components/threadActionMenu.logic.test.ts | 13 ++ .../src/components/threadActionMenu.logic.ts | 15 +++ apps/web/src/contextMenuFallback.ts | 5 + .../web/src/hooks/useThreadActionMenu.test.ts | 2 + apps/web/src/hooks/useThreadActionMenu.ts | 8 +- apps/web/src/hooks/useThreadActions.ts | 11 ++ docs/user/thread-sidebar.md | 6 + .../client-runtime/src/operations/commands.ts | 11 ++ .../src/state/threadCommands.ts | 7 + .../src/state/threadRelationships.test.ts | 71 ++++++++++ .../src/state/threadRelationships.ts | 41 +++++- 12 files changed, 285 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 102d9734ded92..3b6fae56580ff 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -7,6 +7,10 @@ import { SectionHeaderStatus } from "../ui/collapsible-section-header"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { deriveWorkflowGroups, projectedSubagentsToRuntime, @@ -21,6 +25,7 @@ import { threadRelationshipRowStatus, orderWebThreadLineageRows, resolveMergeBackTargetThreadId, + threadPopOutAvailability, type ThreadRelationshipEdge, type ThreadRelationshipWalkRow, } from "@t3tools/client-runtime/state/thread-relationships"; @@ -53,6 +58,7 @@ import { MessagesSquareIcon, MoreHorizontalIcon, PlusIcon, + SquareArrowOutUpRightIcon, SquareIcon, UnplugIcon, } from "lucide-react"; @@ -82,6 +88,8 @@ import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { openSideChatTab } from "../../lib/sideChatPane"; +import { usePopOutThread } from "../../hooks/useThreadActions"; +import { POP_OUT_BLOCKED_HINT } from "../threadActionMenu.logic"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -204,6 +212,9 @@ function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: Thread if (edge.kind === "subagent") { return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent"; } + if (edge.kind === "popped_out") { + return edge.sourceThreadId === currentThreadId ? "Popped out" : "Former parent"; + } return edge.sourceThreadId === currentThreadId ? "Fork" : "Parent thread"; } @@ -441,6 +452,8 @@ export function ThreadRelationshipsPanel(props: { const mergeErrorToastId = useRef(null); const detachErrorToastId = useRef(null); const [stoppingId, setStoppingId] = useState(null); + const popOutThread = usePopOutThread(); + const [poppingOutId, setPoppingOutId] = useState(null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); const derivedProviderEntries = deriveProviderInstanceEntries(providers ?? []); @@ -672,6 +685,21 @@ export function ThreadRelationshipsPanel(props: { } }; + const popOut = async (childThreadId: ThreadId) => { + if (poppingOutId !== null) return; + setPoppingOutId(childThreadId); + const result = await popOutThread(scopeThreadRef(props.environmentId, childThreadId)); + setPoppingOutId(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not pop out thread", + description: error instanceof Error ? error.message : undefined, + }); + } + }; + const stopWorkflow = async (subagentId: string) => { if (stoppingId !== null) return; setStoppingId(subagentId); @@ -705,9 +733,11 @@ export function ThreadRelationshipsPanel(props: { ? CornerLeftUpIcon : isSubagent ? BotIcon - : node?.thread?.lineage.sideChat === true - ? MessagesSquareIcon - : GitForkIcon; + : edge.kind === "popped_out" + ? SquareArrowOutUpRightIcon + : node?.thread?.lineage.sideChat === true + ? MessagesSquareIcon + : GitForkIcon; const relationship = node?.thread?.lineage.sideChat === true && !isParent ? "Side chat" @@ -728,6 +758,16 @@ export function ThreadRelationshipsPanel(props: { ["pending", "running", "waiting"].includes(agent.nativeStatus))) && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); + const popOutAvailability = + agent?.origin === "app_owned" && node?.thread != null + ? threadPopOutAvailability( + node.thread, + projected.filter( + (task) => task.origin === "app_owned" && task.childThreadId === threadId, + ), + ) + : "unsupported"; + const canPopOut = popOutAvailability !== "unsupported"; const threadTitle = relationshipThreadTitle({ title: workflowGroup?.workflow.workflowName ?? @@ -750,7 +790,7 @@ export function ThreadRelationshipsPanel(props: { workflowGroup?.workflow.status === "running" && agent?.origin === "provider_native" && providerDriver === "claudeAgent"; - const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) + const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop || canPopOut) ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""; const project = projects.find((project) => project.id === node?.thread?.projectId); @@ -910,30 +950,59 @@ export function ThreadRelationshipsPanel(props: { ) : ( relationshipLink )} - {canStop && agent ? ( -
- - void stopSubagent(threadId, agent)} - /> - } - > - {stoppingId === threadId ? ( - - ) : ( - - )} - - Stop subagent - + {(canStop && agent) || canPopOut ? ( +
+ {canPopOut ? ( + + void popOut(threadId)} + /> + } + > + {poppingOutId === threadId ? ( + + ) : ( + + )} + + + {popOutAvailability === "blocked" + ? `Pop out to top level. ${POP_OUT_BLOCKED_HINT}.` + : "Pop out to top level"} + + + ) : null} + {canStop && agent ? ( + + void stopSubagent(threadId, agent)} + /> + } + > + {stoppingId === threadId ? ( + + ) : ( + + )} + + Stop subagent + + ) : null}
) : null} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index bd9dfdcdcc4b6..ddfab783f73e1 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -74,4 +74,17 @@ describe("buildThreadActionMenuItems", () => { expect(items.find((item) => item.id === "archive")?.disabled).toBe(true); expect(items.find((item) => item.id === "turn-into-project")?.disabled).toBe(true); }); + + it("offers pop-out only for delegated children, disabled with a hint until it can run", () => { + const popOut = (state: Partial) => + buildThreadActionMenuItems({ ...baseState, ...state }).find((item) => item.id === "pop-out"); + expect(popOut({})).toBeUndefined(); + expect(popOut({ popOut: "unsupported" })).toBeUndefined(); + expect(popOut({ popOut: "available" })?.disabled).toBeUndefined(); + expect(popOut({ popOut: "blocked" })).toMatchObject({ + disabled: true, + detail: "Wait for it to finish or stop it first", + }); + expect(popOut({ popOut: "available", canOperate: false })?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 1e3eddd0b9389..b8fd00bb9d85c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,5 +1,8 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import type { ThreadPopOutAvailability } from "@t3tools/client-runtime/state/thread-relationships"; + +export const POP_OUT_BLOCKED_HINT = "Wait for it to finish or stop it first"; export type ThreadActionMenuId = | "find" @@ -20,6 +23,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" + | "pop-out" | "share-guest" | "copy" | "copy-path" @@ -95,6 +99,7 @@ export interface ThreadActionMenuState { readonly canShareWithGuest?: boolean; /** A No project thread with its own folder, on a server that can promote it. */ readonly canTurnIntoProject?: boolean; + readonly popOut?: ThreadPopOutAvailability; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -179,6 +184,16 @@ export function buildThreadActionMenuItems( ] : []), { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + ...(state.popOut === "available" || state.popOut === "blocked" + ? [ + { + id: "pop-out" as const, + label: "Pop out to top level", + icon: "square-arrow-out-up-right", + ...(state.popOut === "blocked" ? { disabled: true, detail: POP_OUT_BLOCKED_HINT } : {}), + }, + ] + : []), ...(state.canShareWithGuest ? [{ id: "share-guest" as const, label: "Share with guest", icon: "user-plus" }] : []), diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 55041deb60dfb..d3c8ab4ed1fd5 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -109,6 +109,11 @@ const ICON_PATHS: Record ({ worktreePath: null, runtime: null, latestRun: null, + source: { lineage: { parentThreadId: null, relationshipToParent: null } }, }), useProjects: () => [{ id: "project", environmentId: "secondary" }], })); @@ -120,6 +121,7 @@ vi.mock("./useSettings", () => ({ })); vi.mock("./useThreadActions", () => ({ canTurnThreadIntoProject: () => false, + usePopOutThread: () => async () => AsyncResult.success(undefined), useThreadActions: () => Object.fromEntries( [ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index f2d5509c9488f..bb8ac949ca94a 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -43,10 +43,11 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; +import { threadPopOutAvailability } from "@t3tools/client-runtime/state/thread-relationships"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; +import { canTurnThreadIntoProject, usePopOutThread, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -88,6 +89,7 @@ export function useThreadActionMenu(input: { markThreadUnread, turnThreadIntoProject, } = useThreadActions(); + const popOutThread = usePopOutThread(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -152,6 +154,7 @@ export function useThreadActionMenu(input: { snoozePresets, canShareWithGuest, canTurnIntoProject: canTurnThreadIntoProject(threadRef), + popOut: threadPopOutAvailability(thread.source), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -262,6 +265,9 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(threadRef); return; + case "pop-out": + await reportFailure("Failed to pop out thread", () => popOutThread(threadRef)); + return; case "share-guest": openGuestShareDialog(threadRef); return; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e91f170689a56..6ed5a3427878e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -199,6 +199,17 @@ export async function navigateAfterThreadDeletion(navigate: () => Promise) } } +export function usePopOutThread() { + const popOutThreadMutation = useOrchestrationCommand(threadEnvironment.popOut, { + reportFailure: false, + }); + return (target: ScopedThreadRef) => + popOutThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); +} + function useMarkThreadUnread() { const markThreadUnreadMutation = useAtomCommand(threadEnvironment.markUnread, { reportFailure: false, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..3b3bcfc4c105c 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -274,6 +274,12 @@ layouts with an inspector, its thread opens beside the parent. Maximize it to fill the screen; close the full-screen view to return to the parent and inspector. Stop on a thread also stops the subagents it delegated to. +To keep working in a delegated thread on its own, choose **Pop out to top level** +from its thread menu or its row in the parent's **Lineage**. It keeps its history, +model, and folder, gets its own sidebar row, and no longer reports to the parent or +stops with it. It is available once the delegated task has finished and its result +reached the parent; stop it first if you can't wait. + Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent thread asks for it. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f813ece95dc45..a10e0425e978f 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -608,6 +608,17 @@ export const markThreadUnread = Effect.fn("EnvironmentCommands.markThreadUnread" }); }); +export type PopOutThreadInput = ThreadCommandInput; +export const popOutThread = Effect.fn("EnvironmentCommands.popOutThread")(function* ( + input: PopOutThreadInput, +) { + return yield* dispatch({ + type: "thread.detach", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + }); +}); + export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")( function* (input: UpdateThreadMetadataInput) { const commandId = yield* allocateCommandId(input); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 3761d0fa6e715..2f0026a26105e 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -31,6 +31,7 @@ import { forkSideChat, forkThreadFromRun, markThreadUnread, + popOutThread, mergeThreadBack, promoteQueuedRun, resumeThreadQueue, @@ -222,6 +223,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + popOut: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:pop-out", + execute: popOutThread, + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: updateThreadMetadata, diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 90dc68c21dd82..b3a285f94d54d 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -7,6 +7,7 @@ import { immediateThreadRelationships, orderWebThreadLineageRows, resolveMergeBackTargetThreadId, + threadPopOutAvailability, threadRelationshipRowStatus, } from "./threadRelationships.ts"; @@ -207,6 +208,76 @@ describe("thread relationships", () => { ); }); + it("shows a popped-out child to its former parent instead of as its subagent", () => { + const parent = ThreadId.make("thread-parent"); + const child = ThreadId.make("thread-popped"); + const graph = deriveThreadRelationshipGraph({ + threads: [ + { + id: parent, + status: "completed", + forkedFrom: null, + lineage: { rootThreadId: parent, parentThreadId: null, relationshipToParent: null }, + }, + { + id: child, + status: "running", + forkedFrom: { type: "node", nodeId: "task" }, + lineage: { + rootThreadId: parent, + parentThreadId: null, + relationshipToParent: null, + detachedFrom: { threadId: parent }, + }, + }, + ] as never, + projection: { + thread: { id: parent }, + subagents: [{ childThreadId: child, status: "completed" }], + contextTransfers: [{ sourceThreadId: parent, targetThreadId: child, status: "consumed" }], + } as never, + }); + const edgeTo = (from: ThreadId) => + immediateThreadRelationships(graph, from).map((row) => [row.edge.kind, row.edge.status]); + expect(edgeTo(parent)).toEqual([["popped_out", "running"]]); + expect(edgeTo(child)).toEqual([["popped_out", "running"]]); + expect(graph.edges.some((edge) => edge.kind === "subagent")).toBe(false); + }); + + it.each([ + ["an idle delegated child whose tasks reported", {}, [], "available"], + ["a running child", { status: "running" }, [], "blocked"], + ["a child waiting on a question", { pendingRuntimeRequest: {} }, [], "blocked"], + [ + "a child whose result has not reached its parent", + {}, + [{ status: "completed", completionDelivery: { state: "pending" } }], + "blocked", + ], + ["a provider's own subagent", { creationSource: "provider" }, [], "unsupported"], + [ + "a top-level thread", + { lineage: { parentThreadId: null, relationshipToParent: null } }, + [], + "unsupported", + ], + ])("pop-out for %s", (_name, overrides, tasks, expected) => { + const thread = { + creationSource: "server", + lineage: { + parentThreadId: "parent", + relationshipToParent: "subagent", + rootThreadId: "parent", + }, + activityRunStatus: null, + status: "idle", + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + ...overrides, + }; + expect(threadPopOutAvailability(thread as never, tasks as never)).toBe(expected); + }); + it("keeps the live shell when an archived snapshot contains the same thread id", () => { const parent = ThreadId.make("thread-parent"); const staleParent = ThreadId.make("thread-stale-parent"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 2aecc93470c2f..8e125094fa882 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -1,12 +1,16 @@ import { + isDelegatedChildThread, + isDelegatedTaskReported, + isLiveSubagentShell, isOrchestrationV2WorkActive, + type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, type ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "transfer"; +export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "popped_out" | "transfer"; export interface ThreadRelationshipNode { readonly threadId: ThreadId; @@ -86,6 +90,16 @@ export function deriveThreadRelationshipGraph(input: { ? [...threads, { ...selectedThread, activityRunStatus: null, status: null }] : threads; for (const thread of lineageThreads) { + const detachedFrom = thread.lineage.detachedFrom?.threadId; + if (detachedFrom !== undefined) { + addEdge({ + sourceThreadId: detachedFrom, + targetThreadId: thread.id, + kind: "popped_out", + status: thread.activityRunStatus ?? thread.status, + }); + continue; + } const parentThreadId = thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId @@ -104,6 +118,7 @@ export function deriveThreadRelationshipGraph(input: { for (const subagent of input.projection.subagents) { if (subagent.childThreadId === null) continue; const child = threadsById.get(subagent.childThreadId); + if (child?.lineage.detachedFrom?.threadId === ownerThreadId) continue; addEdge({ sourceThreadId: ownerThreadId, targetThreadId: subagent.childThreadId, @@ -205,3 +220,27 @@ export function orderWebThreadLineageRows(input: { return left.threadId < right.threadId ? -1 : left.threadId > right.threadId ? 1 : 0; }); } + +export type ThreadPopOutAvailability = "unsupported" | "blocked" | "available"; + +/** + * Whether a delegated child can be popped out to top level: once it is idle and every task it was + * given has reported to its parent. The server re-checks against the parent's records. + */ +export function threadPopOutAvailability( + thread: Pick< + OrchestrationV2ThreadShell, + | "lineage" + | "creationSource" + | "activityRunStatus" + | "status" + | "pendingRuntimeRequest" + | "pendingBackgroundTasks" + >, + tasks: ReadonlyArray> = [], +): ThreadPopOutAvailability { + if (!isDelegatedChildThread(thread)) return "unsupported"; + return isLiveSubagentShell(thread) || tasks.some((task) => !isDelegatedTaskReported(task, false)) + ? "blocked" + : "available"; +} From 7ac13bf3b52dcc0f89ce8314913c3c8ba615bbda Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:35:43 +0000 Subject: [PATCH 3/6] fix: tighten pop-out guards and keep the tool text within budget --- apps/server/src/mcp/OrchestratorMcpService.ts | 11 +++++++---- apps/server/src/mcp/toolkits/thread/tools.ts | 2 +- apps/server/src/orchestration-v2/Orchestrator.ts | 13 +++++++++---- .../ThreadRelationshipsControl.browser.test.tsx | 3 +++ .../components/chat/ThreadRelationshipsControl.tsx | 8 ++++++-- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 60d9debcd7902..373414bca61d6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2593,10 +2593,13 @@ const make = Effect.gen(function* () { const childShell = yield* threadManagement .getThreadShell(childThreadId) .pipe(Effect.mapError(threadManagementFailure)); - if ( - childShell?.lineage.detachedFrom?.threadId === scope.thread.threadId && - isTerminalTaskStatus(current.status) - ) { + if (childShell?.lineage.detachedFrom?.threadId === scope.thread.threadId) { + if (!isTerminalTaskStatus(current.status)) { + return yield* failure( + "invalid_request", + `Thread ${childThreadId} was popped out to top level and no longer belongs to this task.`, + ); + } return { taskId: input.taskId, status: current.status, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index ce0029322228d..ad6f8684f3506 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -31,7 +31,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread, or `pop_out` a finished delegated child to top level. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.", + "Pin, snooze, settle, archive, mark unread, or pop out a finished child. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread waits for a clean turn end and empty queue.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index eb48b9d0bd70e..a3991afa27974 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4753,9 +4753,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandType: command.type, cause: `Thread ${command.threadId} ${cause}`, }); - const child = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"]).pipe( - mapDispatchError(command), - ); + const child = yield* loadProjectionForCommand(command, [ + "runs", + "runtimeRequests", + "providerThreads", + ]).pipe(mapDispatchError(command)); const thread = child.thread; if (thread.deletedAt !== null) return yield* refuse("is deleted."); if (thread.archivedAt !== null) return yield* refuse("is archived."); @@ -4766,7 +4768,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (!isDelegatedChildThread(thread) || parentThreadId === null) { return yield* refuse("is not a delegated child thread."); } - if (threadHasUnfinishedWork(child)) { + if ( + threadHasUnfinishedWork(child) || + child.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0) + ) { return yield* refuse("is still working. Wait for it to finish or stop it first."); } const tasks = (yield* projectionStore diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 9679f42569c6b..d2f0bbf2ba25b 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -44,6 +44,9 @@ vi.mock(import("../../state/environments"), () => ({ useEnvironment: (() => null) as never, useEnvironmentProviders: (() => new Map()) as never, })); +vi.mock(import("../../hooks/useThreadActions"), () => ({ + usePopOutThread: (() => async () => AsyncResult.success(undefined)) as never, +})); vi.mock(import("../../lib/archivedThreadsState"), () => ({ useArchivedThreadSnapshots: (() => ({ snapshots: [{ environmentId: "environment", snapshot: { threads: state.archivedThreads } }], diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 3b6fae56580ff..e70962a4cd650 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -961,8 +961,12 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" part="icon" aria-label={`Pop out ${threadTitle} to top level`} - disabled={popOutAvailability === "blocked" || poppingOutId !== null} - onClick={() => void popOut(threadId)} + disabled={poppingOutId !== null} + // Still focusable and hoverable, so the blocked hint can show. + aria-disabled={popOutAvailability === "blocked" || undefined} + onClick={() => { + if (popOutAvailability !== "blocked") void popOut(threadId); + }} /> } > From 85ba10ebe73186f75acb8945a472f0ad74b24bd6 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 11 Nov 2026 05:04:30 -0800 Subject: [PATCH 4/6] feat(mobile): pop out a delegated child from its thread header The mobile app could open a delegated child but not pop it out. A delegated child's thread header now offers "Pop out to top level" (in the overflow menu on Android) to users who can operate the thread. It asks for confirmation first; while the child is still working, or its result has not reached the parent, it explains why it can't pop out yet instead of sending a command the server would refuse. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadHeader.tsx | 12 ++++++++ .../features/threads/ThreadRouteScreen.tsx | 30 ++++++++++++++++++- docs/user/thread-sidebar.md | 3 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index d0045bf5f284e..b6ea444cc58a0 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -21,6 +21,8 @@ export function ThreadHeader(props: { readonly hasThreadCwd: boolean; readonly inspectorMode: ThreadInspectorMode | null; readonly onOpenFilesInspector: () => void; + /** Present only on a delegated child the user may pop out. */ + readonly onPopOut?: () => void; }) { const goBack = useWorkspaceBackNavigation("threads"); const { toggleAuxiliaryPane, startNewTask } = useAdaptiveWorkspaceLayout(); @@ -40,6 +42,16 @@ export function ThreadHeader(props: { }, ] : []; + if (props.onPopOut) { + actions.push({ + accessibilityLabel: "Pop out to top level", + icon: "arrow.up.right.circle", + subtitle: "Make it an ordinary thread", + overflowOnly: true, + separateBackground: false, + onPress: props.onPopOut, + }); + } const menus = [gitMenu, terminalMenu].filter((menu): menu is AppBarMenu => menu !== null); return ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ab0b5cdb9bd4d..2c8a597a99f10 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -89,7 +89,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; -import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; +import { + resolveMergeBackTargetThreadId, + threadPopOutAvailability, +} from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; import { threadEnvironment } from "../../state/threads"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; @@ -427,6 +430,30 @@ function ThreadRouteContent( const navigation = useNavigation(); const selectDetail = useSelectWorkspaceDetail(); const openPullRequest = useOpenPullRequest(); + const popOut = useAtomCommand(threadEnvironment.popOut, { reportFailure: false }); + const popOutAvailability = + selectedThread && canOperateThread + ? threadPopOutAvailability(selectedThread.source) + : "unsupported"; + const handlePopOut = () => { + if (!selectedThread) return; + if (popOutAvailability === "blocked") { + Dialog.alert("Can't pop out yet", "Wait for it to finish or stop it first."); + return; + } + const environmentId = selectedThread.environmentId; + const threadId = selectedThread.id; + Dialog.confirm({ + title: "Pop out to top level?", + message: + "It becomes an ordinary thread in your list and stops reporting to the thread that started it.", + confirmText: "Pop out", + onConfirm: () => + void popOut({ environmentId, input: { threadId } }).then((result) => { + if (result._tag !== "Success") Dialog.alert("Could not pop out thread"); + }), + }); + }; const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back"); const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail); const mergeBackRun = @@ -963,6 +990,7 @@ function ThreadRouteContent( inspectorMode={inspectorMode} onOpenFilesInspector={handleOpenFilesInspector} onReturnToThread={props.onReturnToThread} + onPopOut={popOutAvailability === "unsupported" ? undefined : handlePopOut} /> ) } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3b3bcfc4c105c..fba38a365fc9d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -275,7 +275,8 @@ fill the screen; close the full-screen view to return to the parent and inspecto Stop on a thread also stops the subagents it delegated to. To keep working in a delegated thread on its own, choose **Pop out to top level** -from its thread menu or its row in the parent's **Lineage**. It keeps its history, +from its thread menu or its row in the parent's **Lineage**; in the mobile app it's +in the thread's header, under the overflow menu on Android. It keeps its history, model, and folder, gets its own sidebar row, and no longer reports to the parent or stops with it. It is available once the delegated task has finished and its result reached the parent; stop it first if you can't wait. From 764f09ed5100ed7d7b335a26345f9609fc2f9790 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 03:50:03 +0000 Subject: [PATCH 5/6] fix: keep delegated pop-out and parent stops consistent --- .../features/threads/ThreadRouteScreen.tsx | 8 +++- apps/server/src/mcp/toolkits/core.test.ts | 20 ++++++--- .../src/mcp/toolkits/thread/handlers.ts | 9 ++-- .../src/orchestration-v2/Orchestrator.ts | 19 ++++++++- .../ThreadManagementService.ts | 11 ++++- .../src/orchestration-v2/ThreadStop.test.ts | 7 ++++ .../chat/ThreadRelationshipsControl.tsx | 1 + .../web/src/hooks/useThreadActionMenu.test.ts | 41 ++++++++++++++++++- apps/web/src/hooks/useThreadActionMenu.ts | 26 +++++++++--- .../client-runtime/src/state/orchestration.ts | 6 +++ .../src/state/threadRelationships.test.ts | 14 ++++++- .../src/state/threadRelationships.ts | 19 ++++++++- 12 files changed, 158 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3637c9b6e506c..4a6972d6363fe 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -470,7 +470,13 @@ function ThreadRouteContent( confirmText: "Pop out", onConfirm: () => void popOut({ environmentId, input: { threadId } }).then((result) => { - if (result._tag !== "Success") Dialog.alert("Could not pop out thread"); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not pop out thread", + error instanceof Error ? error.message : "Try again.", + ); + } }), }); }; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 78e83b19c985f..bd29856af842f 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -605,12 +605,12 @@ it.effect("rejects an unknown organize target instead of archiving the calling t ); }); -it.effect("refuses to let an agent pop its own thread out, but not another thread", () => { +it.effect("allows an agent to pop out only its own child", () => { const dispatched: Array = []; return Effect.gen(function* () { const server = yield* McpServer.McpServer; const otherThreadId = ThreadId.make("mcp-core-child"); - for (const target of [undefined, threadId, otherThreadId]) { + for (const target of [undefined, threadId, otherThreadId, ThreadId.make("unrelated")]) { const result = yield* server .callTool({ name: "t3_thread_organize", @@ -636,10 +636,18 @@ it.effect("refuses to let an agent pop its own thread out, but not another threa Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), - getProjectThreadRecords: ({ threadId: id }) => - Effect.succeed( - McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), - ), + getProjectThreadRecords: ({ threadId: id }) => { + const shell = McpToolAccessTestkit.liveThreadShell(id); + return Effect.succeed( + McpToolAccessTestkit.idleThreadProjection({ + ...shell, + lineage: { + ...shell.lineage, + parentThreadId: id === ThreadId.make("mcp-core-child") ? threadId : null, + }, + }), + ); + }, dispatch: (command) => { dispatched.push(command); return Effect.succeed({ sequence: 1 } as never); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 21bb515618897..783296b87e790 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -344,7 +344,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { ), t3_thread_organize: writesThread((input) => Effect.gen(function* () { - const { threads, projection, caller } = yield* readThread(input.threadId); + const { threads, projection, caller, scope } = yield* readThread(input.threadId); const common = { commandId: yield* newCommandId(), threadId: projection.thread.id }; if (input.action === "settle") { return yield* threads @@ -372,9 +372,10 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { case "pop_out": // Otherwise a child could leave its parent's Stop on its own. if ( - caller !== undefined && - (caller.id === projection.thread.id || - caller.id !== projection.thread.lineage.parentThreadId) + scope.client?.senderThread !== undefined || + (caller !== undefined && + (caller.id === projection.thread.id || + caller.id !== projection.thread.lineage.parentThreadId)) ) { return yield* new OrchestratorMcpFailure({ code: "invalid_request", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ac5bb3735b1ea..069a57da71eaf 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -389,6 +389,10 @@ export interface OrchestratorV2DispatchResult { } export interface OrchestratorV2Shape { + readonly withDelegatedTasksStopLock: ( + threadId: ThreadId, + walk: (dispatch: OrchestratorV2Shape["dispatch"]) => Effect.Effect, + ) => Effect.Effect; readonly resumeQueuedRuns: Effect.Effect; readonly recoverDelegatedTasks: Effect.Effect; readonly recoverDelegatedTask: ( @@ -13789,7 +13793,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( command.expectedParentThreadId !== undefined && projection.thread.lineage.parentThreadId !== command.expectedParentThreadId - ) return undefined; + ) + return undefined; // Queued after the interrupt, since a thread's effects run in order. const stopDelegatedTasks = command.stopDelegatedTasks === false || @@ -16178,6 +16183,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio { messageRoles: ["user"] }, ) .pipe(mapDispatchError(command)); + if (child.thread.lineage.parentThreadId !== input.parentThreadId) return; const followUpRun = child.runs .filter((run) => run.delegatedTaskId === input.taskId) .toSorted((a, b) => b.ordinal - a.ordinal)[0]; @@ -16234,6 +16240,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio new OrchestratorProjectionError({ threadId: ThreadId.make("thread:shell"), cause }); return OrchestratorV2.of({ + withDelegatedTasksStopLock: Effect.fn("Orchestrator.withDelegatedTasksStopLock")( + (threadId, walk) => + threadDispatch.withLock( + threadId, + walk((command) => + commandThreadId(command) === threadId + ? dispatchWithReceiptEffect(command) + : dispatchWithReceipt(command), + ), + ), + ), resumeQueuedRuns, recoverDelegatedTasks, recoverDelegatedTask, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 29184c08bd15b..96a6a36020f59 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1611,6 +1611,11 @@ const make = Effect.gen(function* () { const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( "ThreadManagementService.stopDelegatedTasks", )(function* (input) { + if (input.remoteDispatch === undefined) { + return yield* orchestrator.withDelegatedTasksStopLock(input.threadId, (remoteDispatch) => + stopDelegatedTasks({ ...input, remoteDispatch }), + ); + } const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); // A child popped out to top level no longer answers to this parent's Stop. const attachedChildThreadIds = new Set( @@ -1673,7 +1678,11 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const child = yield* orchestrator.getThreadRecords(threadId, []); if (child.thread.lineage.parentThreadId !== input.threadId) return; - yield* stopDelegatedTasks({ threadId, commandId: input.commandId, reason: input.reason }); + yield* stopDelegatedTasks({ + threadId, + commandId: input.commandId, + reason: input.reason, + }); }), ), Effect.catch((error) => diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 52cf100eaba9f..10f635bf0c60a 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -638,6 +638,13 @@ it.effect("a parent's Stop no longer reaches a child popped out of it", () => }, }); + yield* orchestrator.dispatch({ + type: "thread.stop", + threadId: childThreadId, + commandId: CommandId.make("stop-popped-stale-parent"), + expectedParentThreadId: parentThreadId, + }); + assert.deepEqual(yield* threadState(childThreadId), before); yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: CommandId.make("stop-popped-parent"), diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 666a713d7f729..0afc83a9e8537 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -792,6 +792,7 @@ export function ThreadRelationshipsPanel(props: { projected.filter( (task) => task.origin === "app_owned" && task.childThreadId === threadId, ), + projection?.runs, ) : "unsupported"; const canPopOut = popOutAvailability !== "unsupported"; diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 28c742116e5cc..ee997951c8680 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -21,6 +21,8 @@ const state = vi.hoisted(() => ({ granted: new Set(), effects: [] as string[], completed: deferred(), + child: false, + delivery: "pending" as "pending" | "acknowledged", show: vi.fn< ( items: ReadonlyArray>, @@ -58,12 +60,34 @@ vi.mock("../state/entities", () => ({ worktreePath: null, runtime: null, latestRun: null, - source: { lineage: { parentThreadId: null, relationshipToParent: null } }, + source: { + creationSource: "server", + status: "completed", + pendingRuntimeRequest: null, + lineage: { + parentThreadId: state.child ? "parent" : null, + relationshipToParent: state.child ? "subagent" : null, + }, + }, }), useProjects: () => [{ id: "project", environmentId: "secondary" }], })); vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" })); vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } })); +vi.mock("../state/orchestration", () => ({ orchestrationEnvironment: { popOutParent: "parent" } })); +vi.mock("../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => async () => + AsyncResult.success({ + subagents: [ + { + childThreadId: "thread", + status: "completed", + completionDelivery: { state: state.delivery }, + }, + ], + runs: [], + }), +})); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => async () => { recordEffect("metadata"); @@ -164,10 +188,25 @@ beforeEach(() => { state.granted = new Set(["primary"]); state.effects = []; state.completed = deferred(); + state.child = false; + state.delivery = "pending"; state.show.mockReset().mockResolvedValue(null); }); describe("thread menu permissions", () => { + it.each(["pending", "acknowledged"] as const)( + "pop-out waits for %s completion delivery", + async (delivery) => { + state.granted.add("secondary"); + state.child = true; + state.delivery = delivery; + createMenu().openMenu(position); + await vi.waitFor(() => expect(state.show).toHaveBeenCalledOnce()); + const item = state.show.mock.calls[0]![0].find((item) => item.id === "pop-out"); + expect(item).toBeDefined(); + expect(item?.disabled === true).toBe(delivery === "pending"); + }, + ); it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( "%s rechecks after the native menu closes", async (action) => { diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 35d09c93f5f34..2ef40336f5312 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -8,11 +8,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import { - AuthOrchestrationOperateScope, - type ScopedThreadRef, - type ThreadId, -} from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { pickSnoozePreset, resolveSnoozePresets } from "../components/Sidebar.snooze"; @@ -45,6 +41,8 @@ import { import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { threadPopOutAvailability } from "@t3tools/client-runtime/state/thread-relationships"; +import { orchestrationEnvironment } from "../state/orchestration"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; @@ -91,6 +89,7 @@ export function useThreadActionMenu(input: { turnThreadIntoProject, } = useThreadActions(); const popOutThread = usePopOutThread(); + const loadPopOutParent = useAtomQueryRunner(orchestrationEnvironment.popOutParent); const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, { reportFailure: false, }); @@ -130,6 +129,21 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); + let popOut = threadPopOutAvailability(thread.source); + if (popOut === "available" && thread.source.lineage.parentThreadId !== null) { + const parent = await loadPopOutParent({ + environmentId: threadRef.environmentId, + input: { threadId: ThreadId.make(thread.source.lineage.parentThreadId) }, + }); + popOut = + parent._tag === "Success" + ? threadPopOutAvailability( + thread.source, + parent.value.subagents.filter((task) => task.childThreadId === threadRef.threadId), + parent.value.runs, + ) + : "blocked"; + } const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), canFind: onFind !== undefined, @@ -146,7 +160,7 @@ export function useThreadActionMenu(input: { snoozePresets, canShareWithGuest, canTurnIntoProject: canTurnThreadIntoProject(threadRef), - popOut: threadPopOutAvailability(thread.source), + popOut, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f22ac79383d66..130f2ad107387 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -253,6 +253,12 @@ export function createOrchestrationEnvironmentAtoms( staleTimeMs: 0, idleTtlMs: 0, }), + popOutParent: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:pop-out-parent", + tag: ORCHESTRATION_V2_WS_METHODS.getThreadProjection, + staleTimeMs: 0, + idleTtlMs: 0, + }), // Read right before deleting a worktree, so never served from cache. worktreeThreadIds: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:worktree-thread-ids", diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index b3a285f94d54d..a88eae25e25ad 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -255,6 +255,12 @@ describe("thread relationships", () => { "blocked", ], ["a provider's own subagent", { creationSource: "provider" }, [], "unsupported"], + [ + "a terminal task whose spawning run is still live", + {}, + [{ status: "completed", runId: "parent-run" }], + "blocked", + ], [ "a top-level thread", { lineage: { parentThreadId: null, relationshipToParent: null } }, @@ -275,7 +281,13 @@ describe("thread relationships", () => { pendingBackgroundTasks: [], ...overrides, }; - expect(threadPopOutAvailability(thread as never, tasks as never)).toBe(expected); + expect( + threadPopOutAvailability( + thread as never, + tasks as never, + [{ id: "parent-run", status: "running" }] as never, + ), + ).toBe(expected); }); it("keeps the live shell when an archived snapshot contains the same thread id", () => { diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index d835588727dc5..a546ee6476ef9 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -4,6 +4,7 @@ import { isLiveSubagentShell, isOrchestrationV2WorkActive, type OrchestrationV2Subagent, + type OrchestrationV2Run, type OrchestrationV2ThreadShell, type ThreadId, } from "@t3tools/contracts"; @@ -238,10 +239,24 @@ export function threadPopOutAvailability( | "pendingRuntimeRequest" | "pendingBackgroundTasks" >, - tasks: ReadonlyArray> = [], + tasks: ReadonlyArray< + Pick + > = [], + runs: ReadonlyArray> = [], ): ThreadPopOutAvailability { if (!isDelegatedChildThread(thread)) return "unsupported"; - return isLiveSubagentShell(thread) || tasks.some((task) => !isDelegatedTaskReported(task, false)) + return isLiveSubagentShell(thread) || + tasks.some( + (task) => + !isDelegatedTaskReported( + task, + runs.some( + (run) => + run.id === task.runId && + (run.status === "preparing" || run.status === "starting" || run.status === "running"), + ), + ), + ) ? "blocked" : "available"; } From dcb85fe7c7cf4dbb61d779308bae210f57da95da Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:03:20 +0000 Subject: [PATCH 6/6] fix: keep pop-out available after shell reloads --- .../features/threads/ThreadRouteScreen.tsx | 30 ++++++++++++------- .../chat/ThreadRelationshipsControl.tsx | 15 ++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 4a6972d6363fe..9bda468b09c96 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -451,12 +451,13 @@ function ThreadRouteContent( const selectDetail = useSelectWorkspaceDetail(); const openPullRequest = useOpenPullRequest(); const popOut = useAtomCommand(threadEnvironment.popOut, { reportFailure: false }); + const popOutBusyRef = useRef(false); const popOutAvailability = selectedThread && canOperateThread ? threadPopOutAvailability(selectedThread.source) : "unsupported"; const handlePopOut = () => { - if (!selectedThread) return; + if (!selectedThread || popOutBusyRef.current) return; if (popOutAvailability === "blocked") { Dialog.alert("Can't pop out yet", "Wait for it to finish or stop it first."); return; @@ -468,16 +469,23 @@ function ThreadRouteContent( message: "It becomes an ordinary thread in your list and stops reporting to the thread that started it.", confirmText: "Pop out", - onConfirm: () => - void popOut({ environmentId, input: { threadId } }).then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Dialog.alert( - "Could not pop out thread", - error instanceof Error ? error.message : "Try again.", - ); - } - }), + onConfirm: () => { + if (popOutBusyRef.current) return; + popOutBusyRef.current = true; + void popOut({ environmentId, input: { threadId } }) + .then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not pop out thread", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + popOutBusyRef.current = false; + }); + }, }); }; const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back"); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 0afc83a9e8537..455b265f80136 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -443,6 +443,20 @@ export function ThreadRelationshipsPanel(props: { const providersByEnvironment = useEnvironmentProviders(); const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); const relationshipShells = useThreadRelationshipShells(ref); + const family = useEnvironmentQuery( + props.showLineage && + projection?.subagents.some( + (task) => + task.origin === "app_owned" && + task.childThreadId !== null && + !relationshipShells.some((thread) => thread.id === task.childThreadId), + ) + ? orchestrationEnvironment.threadFamily({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }) + : null, + ); const hiddenKeys = useHiddenThreadKeys(); const hidingAnywhere = useAnyEnvironmentHiding(); const allProjects = useProjects(); @@ -522,6 +536,7 @@ export function ThreadRelationshipsPanel(props: { ); const shells: ReadonlyArray = [ ...relationshipShells, + ...(family.data?.threads ?? []), ...(archivedShells ?? []), ]; const graph = deriveThreadRelationshipGraph({ threads: shells, projection });