From 28f2d25d911d4485f194a2152da0b5017df2eab4 Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 13:55:57 -0300 Subject: [PATCH 1/8] feat(web): promote a subagent thread to its own sidebar row Adds promotedAt with thread.promote/thread.demote. Lineage is unchanged, so the parent still receives the child's result. Promoted subagents are listed as top-level threads in the sidebar, palette, composer mentions and shell stream. Co-Authored-By: Claude Fable 5.2 --- .../src/features/keyboard/CommandPalette.tsx | 7 +- .../src/features/threads/threadListV2.ts | 7 +- .../Orchestrator.control-reads.test.ts | 69 ++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 27 +++++ .../orchestration-v2/ProjectionMaintenance.ts | 2 + .../src/orchestration-v2/ProjectionStore.ts | 26 ++++- .../src/orchestration-v2/ShellStream.test.ts | 16 +++ .../src/orchestration-v2/ShellStream.ts | 21 ++-- .../testkit/OrchestratorScenario.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 2 + .../components/BackgroundThreadAnnouncer.tsx | 6 +- apps/web/src/components/Sidebar.logic.test.ts | 11 ++ apps/web/src/components/Sidebar.logic.ts | 24 ++-- .../ThreadNotificationCoordinator.tsx | 2 +- .../chat/ThreadRelationshipsControl.tsx | 104 ++++++++++++++---- .../client-runtime/src/composerThreadItems.ts | 3 +- .../client-runtime/src/operations/commands.ts | 14 +++ packages/client-runtime/src/state/models.ts | 2 + .../src/state/olderSettledThreads.ts | 3 +- .../src/state/orchestrationV2Projection.ts | 2 + .../src/state/threadCommands.ts | 14 +++ .../client-runtime/src/state/threadShell.ts | 14 ++- packages/contracts/src/orchestrationV2.ts | 34 +++++- 23 files changed, 350 insertions(+), 62 deletions(-) diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf00..1f7fefdd7a97b 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isNestedSubagentThread, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -422,10 +422,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && !isNestedSubagentThread(thread)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ad02dd1192d63..f4876e801a9d6 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -28,7 +28,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { isNestedSubagentThread, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -265,8 +265,7 @@ export function getThreadListV2OrderedSection(input: { }): EnvironmentThreadShell[] { const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; const threads = input.threads.filter((thread) => { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - return false; + if (thread.archivedAt !== null || isNestedSubagentThread(thread)) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -647,7 +646,7 @@ export function buildThreadListV2Items(input: { const listedThreads = input.threads.filter( (thread) => thread.archivedAt === null && - thread.lineage.relationshipToParent !== "subagent" && + !isNestedSubagentThread(thread) && (input.environmentId === null || thread.environmentId === input.environmentId) && (projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 61515a053ac36..d35d77715cb08 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1488,6 +1488,75 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r }).pipe(Effect.provide(layerTest)), ); +it.effect("promoting a subagent thread lists it on its own without changing its lineage", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventSink.EventSinkV2; + const parentId = ThreadId.make("thread:promote-parent"); + const childId = ThreadId.make("thread:promote-child"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-promote-parent"), + threadId: parentId, + projectId: ProjectId.make("project:promote"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const lineage = { + parentThreadId: parentId, + rootThreadId: parentId, + relationshipToParent: "subagent", + } as const; + yield* events.write({ + events: [ + { + id: EventId.make("promote-child-created"), + type: "thread.created", + threadId: childId, + occurredAt: yield* DateTime.now, + payload: { ...(yield* projections.getThread(parentId)), id: childId, lineage }, + }, + ], + }); + const childIndex = Effect.map(projections.listActiveShellIndex(), (index) => + index.find((entry) => entry.id === childId), + ); + + const promote = CommandId.make("promote-child"); + yield* orchestrator.dispatch({ type: "thread.promote", commandId: promote, threadId: childId }); + const promoted = { lineage, promotedAt: yield* DateTime.now }; + const committed = yield* events.readByCommandId({ commandId: promote }).pipe(Stream.runCollect); + assert.equal(committed[0]?.event.type, "thread.promoted"); + assert.deepInclude(yield* projections.getThreadShell(childId), promoted); + assert.deepInclude(yield* childIndex, promoted); + assert.notInclude(yield* projections.listActiveSubagentThreadIds(), childId); + + yield* orchestrator.dispatch({ + type: "thread.demote", + commandId: CommandId.make("demote-child"), + threadId: childId, + }); + assert.deepInclude(yield* projections.getThreadShell(childId), { lineage, promotedAt: null }); + assert.include(yield* projections.listActiveSubagentThreadIds(), childId); + + const rejected = yield* orchestrator + .dispatch({ + type: "thread.promote", + commandId: CommandId.make("promote-parent"), + threadId: parentId, + }) + .pipe(Effect.flip); + assert.include(String(rejected.cause), "not a subagent"); + }).pipe(Effect.provide(layerTest)), +); + it.effect("dispatch records the command outcome and its duration", () => 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 c464a8743f3c2..1f8eaa68ff3f2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -497,6 +497,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.auto-settle.set": case "thread.pin": case "thread.unpin": + case "thread.promote": + case "thread.demote": case "thread.pin.reorder": case "thread.active.reorder": case "thread.visit": @@ -2830,6 +2832,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.auto-settle.set" | "thread.pin" | "thread.unpin" + | "thread.promote" + | "thread.demote" | "thread.pin.reorder" | "thread.active.reorder" | "thread.mark-unread" @@ -2977,6 +2981,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.auto-settle.set" || command.type === "thread.pin" || command.type === "thread.unpin" || + command.type === "thread.promote" || + command.type === "thread.demote" || command.type === "thread.pin.reorder" || command.type === "thread.active.reorder" || command.type === "thread.pull-request.sync") && @@ -2988,6 +2994,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } + if (command.type === "thread.promote" && thread.lineage.relationshipToParent !== "subagent") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is not a subagent and already has its own row.`, + }); + } // Reject reorder after unpin so a raced request cannot restore a cleared pin. if (command.type === "thread.pin.reorder" && thread.pinnedAt == null) { return yield* new OrchestratorDispatchError({ @@ -3351,6 +3364,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: alreadyUnpinned ? thread.updatedAt : now, }; } + case "thread.promote": + return thread.promotedAt != null + ? thread + : { ...thread, promotedAt: now, updatedAt: now }; + case "thread.demote": + return thread.promotedAt == null + ? thread + : { ...thread, promotedAt: null, updatedAt: now }; case "thread.pin.reorder": { const keyUnchanged = thread.pinOrderKey === command.orderKey; return { @@ -3733,6 +3754,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.pinned" as const; case "thread.unpin": return "thread.unpinned" as const; + case "thread.promote": + return "thread.promoted" as const; + case "thread.demote": + return "thread.demoted" as const; case "thread.pin.reorder": return "thread.pin-reordered" as const; case "thread.active.reorder": @@ -12549,6 +12574,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.auto-settle.set": case "thread.pin": case "thread.unpin": + case "thread.promote": + case "thread.demote": case "thread.pin.reorder": case "thread.active.reorder": case "thread.mark-unread": diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index aad9d355924be..08cf7aa7eba7d 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -207,6 +207,8 @@ export const layer: Layer.Layer< "thread.pinned", "thread.auto-settle-set", "thread.unpinned", + "thread.promoted", + "thread.demoted", "thread.pin-reordered", "thread.active-reordered", "thread.metadata-updated", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6bb04041c75b6..ab5c72ecbbbd4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -203,6 +203,7 @@ export type ProjectionSettlementCandidate = Pick< | "archivedAt" | "settledOverride" | "pinnedAt" + | "promotedAt" | "autoSettleDisabledAt" | "snoozedUntil" | "snoozedAt" @@ -396,7 +397,14 @@ export interface ShellSnapshotOptions { /** What a shell subscriber's trims read from an active thread, without building its shell. */ export interface ActiveShellIndexEntry extends Pick< OrchestrationV2ThreadShell, - "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" + | "id" + | "projectId" + | "lineage" + | "promotedAt" + | "settledOverride" + | "settledAt" + | "pinnedAt" + | "snoozedUntil" > { /** False only when no run, request, work item, or watch can make the shell live. */ readonly mayBeLive: boolean; @@ -776,6 +784,8 @@ export function applyToProjection( case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": + case "thread.promoted": + case "thread.demoted": case "thread.pin-reordered": case "thread.metadata-updated": case "thread.runtime-mode-updated": @@ -1544,6 +1554,7 @@ export function threadShellFromProjection( snoozedUntil: projection.thread.snoozedUntil ?? null, snoozedAt: projection.thread.snoozedAt ?? null, pinnedAt: projection.thread.pinnedAt ?? null, + promotedAt: projection.thread.promotedAt ?? null, autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, @@ -1810,6 +1821,7 @@ function shellFromState(input: { snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, pinnedAt: input.state.thread.pinnedAt ?? null, + promotedAt: input.state.thread.promotedAt ?? null, autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, @@ -1869,6 +1881,8 @@ export const layer: Layer.Layer = case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": + case "thread.promoted": + case "thread.demoted": case "thread.pin-reordered": case "thread.active-reordered": case "thread.visited": @@ -2675,6 +2689,8 @@ export const layer: Layer.Layer = event.type !== "thread.auto-settle-set" && event.type !== "thread.pinned" && event.type !== "thread.unpinned" && + event.type !== "thread.promoted" && + event.type !== "thread.demoted" && event.type !== "thread.pin-reordered" && event.type !== "thread.active-reordered" && event.type !== "thread.visited" && @@ -6188,6 +6204,7 @@ export const layer: Layer.Layer = return { ...thread, pinnedAt: thread.pinnedAt ?? null, + promotedAt: thread.promotedAt ?? null, autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, @@ -6624,6 +6641,7 @@ export const layer: Layer.Layer = WHERE deleted_at IS NULL AND json_extract(payload_json, '$.archivedAt') IS NULL AND json_extract(payload_json, '$.lineage.relationshipToParent') = 'subagent' + AND json_extract(payload_json, '$.promotedAt') IS NULL `; return rows.map((row) => ThreadId.make(row.thread_id)); }, @@ -6647,6 +6665,7 @@ export const layer: Layer.Layer = readonly settled_override: string | null; readonly settled_at: string | null; readonly pinned_at: string | null; + readonly promoted_at: string | null; readonly snoozed_until: string | null; readonly may_be_live: number; }>` @@ -6659,6 +6678,7 @@ export const layer: Layer.Layer = json_extract(t.payload_json, '$.settledOverride') AS settled_override, json_extract(t.payload_json, '$.settledAt') AS settled_at, json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, + json_extract(t.payload_json, '$.promotedAt') AS promoted_at, json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until, -- A superset of isLiveSubagentShell: the sources of its run status, pending request, -- and pending background work. @@ -6717,6 +6737,7 @@ export const layer: Layer.Layer = : null, settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), + promotedAt: row.promoted_at === null ? null : DateTime.makeUnsafe(row.promoted_at), snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), mayBeLive: row.may_be_live === 1, })); @@ -6744,7 +6765,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL AND json_extract(t.payload_json, '$.archivedAt') IS NULL - AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND (json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + OR json_extract(t.payload_json, '$.promotedAt') IS NOT NULL) AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index e308c74a00ff9..b7d04b195d7ca 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -181,6 +181,8 @@ describe("omitIdleSubagentShells", () => { // Idle, but a woken grandchild walks through it to reach the root's badge. row("middle", "root"), row("grandchild", "middle"), + // Idle, but the user gave it a row of its own. + row("promoted", "root", { promotedAt: DateTime.makeUnsafe(0) }), ]; expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ "root", @@ -189,6 +191,7 @@ describe("omitIdleSubagentShells", () => { "asking", "background", "middle", + "promoted", ]); }); @@ -203,6 +206,19 @@ describe("omitIdleSubagentShells", () => { ), ).toBe(false); }); + + it("publishes the transcript events of a child once it is promoted, until it is demoted", () => { + const subagents = new Set(["child"]); + const publishes = (type: string) => + shouldPublishShellApplicationEvent( + { event: { threadId: "child", type } } as ApplicationStoredEvent, + subagents, + ); + expect(publishes("thread.promoted")).toBe(true); + expect(publishes("turn-item.updated")).toBe(true); + expect(publishes("thread.demoted")).toBe(true); + expect(publishes("turn-item.updated")).toBe(false); + }); }); describe("omitOlderSettledShells", () => { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 67d07d6684c08..f4b4a75f2bdfd 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { isLiveSubagentShell, + isNestedSubagentThread, isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, OrchestrationV2ThreadShell as ThreadShellSchema, @@ -73,7 +74,7 @@ function buildActiveShellSnapshot(input: { type ShellTrimRow = Pick< OrchestrationV2ThreadShell, - "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" + "id" | "projectId" | "lineage" | "promotedAt" | "settledOverride" | "settledAt" >; /** @@ -95,7 +96,7 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { const subagentThreadIds = new Set(); const settledTimes: Array = []; for (const row of rows) { - if (row.lineage.relationshipToParent === "subagent") { + if (isNestedSubagentThread(row)) { subagentThreadIds.add(row.id); if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); } else if (row.settledOverride === "settled" && row.settledAt !== null) { @@ -121,10 +122,12 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { }; } -function subagentAncestorIds(threads: ReadonlyArray>) { +function subagentAncestorIds( + threads: ReadonlyArray>, +) { const ancestorIds = new Set(); for (const thread of threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; + if (!isNestedSubagentThread(thread)) continue; if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); ancestorIds.add(thread.lineage.rootThreadId); } @@ -137,7 +140,7 @@ function omitIdleSubagentShellsWith( ): ReadonlyArray { return threads.filter( (thread) => - thread.lineage.relationshipToParent !== "subagent" || + !isNestedSubagentThread(thread) || isLiveSubagentShell(thread) || facts.subagentParentIds.has(thread.id), ); @@ -252,14 +255,14 @@ function shellSubscriberThreadIds( const facts = shellTrimFacts(index); const subagents = index.filter( (entry) => - entry.lineage.relationshipToParent === "subagent" && + isNestedSubagentThread(entry) && (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), ); const ancestorIds = subagentAncestorIds(subagents); const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); const beforeMs = facts.olderSettledBeforeMs; return index.flatMap((entry) => - entry.lineage.relationshipToParent === "subagent" + isNestedSubagentThread(entry) ? keptSubagentIds.has(entry.id) ? [entry.id] : [] @@ -408,11 +411,13 @@ export function shouldPublishShellApplicationEvent( if ("aggregateKind" in stored) return true; const { event } = stored; if (event.type === "thread.created") { - if (event.payload.lineage.relationshipToParent === "subagent") { + if (isNestedSubagentThread(event.payload)) { subagentThreadIds.add(event.threadId); } return true; } + if (event.type === "thread.promoted") subagentThreadIds.delete(event.threadId); + if (event.type === "thread.demoted") subagentThreadIds.add(event.threadId); 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/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 77e8bae8f085f..3e84558f4d732 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -145,6 +145,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { const loopA = subagentOf("thread-loop-a", ThreadId.make("thread-loop-b")); const loopB = subagentOf("thread-loop-b", loopA.id); expect(resolveSidebarRowThreadId(loopA.id, environmentId, [loopA, loopB])).toBe(loopA.id); + // A promoted subagent has its own row, and its children resolve to it. + const promoted = { ...child, promotedAt: "2026-01-01T00:00:00.000Z" }; + const promotedFamily = [root, promoted, grandchild]; + expect(resolveSidebarRowThreadId(promoted.id, environmentId, promotedFamily)).toBe(promoted.id); + expect(resolveSidebarRowThreadId(grandchild.id, environmentId, promotedFamily)).toBe( + promoted.id, + ); + expect( + filterSidebarV2VisibleThreads(promotedFamily, null, new Set()).map((thread) => thread.id), + ).toEqual([root.id, promoted.id]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..98e452a8be07b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -14,7 +14,12 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + isNestedSubagentThread, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { @@ -541,8 +546,10 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarSubagentThread( + thread: Pick, +): boolean { + return isNestedSubagentThread(thread); } // Subagent threads have no sidebar row, so an open subagent is represented by @@ -551,7 +558,7 @@ export function isSidebarSubagentThread(thread: Pick[], + threads: readonly Pick[], ): ThreadId { const findThread = (id: ThreadId) => threads.find((thread) => thread.id === id && thread.environmentId === environmentId); @@ -570,7 +577,10 @@ export function resolveSidebarRowThreadId( * the sidebar lists, by thread key; they show under that thread, as subagents do. */ export function filterSidebarV2VisibleThreads< - T extends Pick & { + T extends Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "id" | "lineage" | "promotedAt" + > & { projectId: string; }, >( @@ -583,7 +593,7 @@ export function filterSidebarV2VisibleThreads< thread.archivedAt === null && !isShownUnderParent( { environmentId: thread.environmentId, threadId: thread.id }, - thread.lineage, + thread, delegatedUnderParent, ) && (scopedProjectKeys === null || @@ -1324,7 +1334,7 @@ export function sortLogicalProjectsForSidebar< export function sortSidebarV2ProjectGroups< TProject extends LogicalSidebarProject, TThread extends ScopedSidebarThread & - Pick, + Pick, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fa4fdbb672faa..ad563a4e59357 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -184,7 +184,7 @@ export function startThreadNotifications( if ( isShownUnderParent( { environmentId, threadId: rawThread.id }, - rawThread.lineage, + rawThread, delegatedUnderParent, ) ) { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 69403392b5ce8..e423f2ad6ebf6 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -45,8 +45,10 @@ import * as DateTime from "effect/DateTime"; import { useNavigate } from "@tanstack/react-router"; import { ArrowRightIcon, + ArrowUpRightIcon, BotIcon, ChevronDownIcon, + CornerDownRightIcon, CornerLeftUpIcon, GitForkIcon, MoreHorizontalIcon, @@ -380,11 +382,13 @@ export function ThreadRelationshipsPanel(props: { const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); + const promoteThread = useAtomCommand(threadEnvironment.promote); + const demoteThread = useAtomCommand(threadEnvironment.demote); const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); const canOperateWorkflow = useAtomValue( orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), ); - const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); + const [busyAction, setBusyAction] = useState<"merge" | "detach" | "promote" | null>(null); const [stoppingId, setStoppingId] = useState(null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); @@ -442,6 +446,8 @@ export function ThreadRelationshipsPanel(props: { }); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; + const isSubagent = currentThread?.lineage.relationshipToParent === "subagent"; + const promoted = currentThread?.promotedAt != null; const { related = [], @@ -515,6 +521,24 @@ export function ThreadRelationshipsPanel(props: { setBusyAction(null); }; + const togglePromoted = async () => { + if (busyAction !== null) return; + setBusyAction("promote"); + const result = await (promoted ? demoteThread : promoteThread)({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }); + setBusyAction(null); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: promoted + ? "Could not move thread back under its parent" + : "Could not promote thread", + }); + } + }; + const stopSubagent = async ( childThreadId: ThreadId, agent: Pick, @@ -557,34 +581,68 @@ export function ThreadRelationshipsPanel(props: { showHeading={ runningCount > 0 || canDetach || + isSubagent || related.length > 0 || active.length > 0 || delegatedFrom !== undefined } data-thread-relationships-panel actions={ - canDetach ? ( - - - } - > - - - - void detach()}> - - Disconnect agent session - - - + isSubagent || canDetach ? ( + <> + {isSubagent ? ( + + void togglePromoted()} + /> + } + > + {busyAction === "promote" ? ( + + ) : promoted ? ( + + ) : ( + + )} + + + {promoted + ? "Hide this thread from the sidebar again; it stays under its parent" + : "Give this subagent its own row in the sidebar"} + + + ) : null} + {canDetach ? ( + + + } + > + + + + void detach()}> + + Disconnect agent session + + + + ) : null} + ) : null } > diff --git a/packages/client-runtime/src/composerThreadItems.ts b/packages/client-runtime/src/composerThreadItems.ts index e3172715ab2b5..4c4cd8cb4cf1c 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -1,3 +1,4 @@ +import { isNestedSubagentThread } from "@t3tools/contracts"; import type { EnvironmentId, EnvironmentMachineKind, @@ -46,7 +47,7 @@ export function matchComposerThreadItems(input: { input.linkedEnvironments.has(shell.environmentId)) && shell.id !== input.excludeThreadId && shell.archivedAt === null && - shell.lineage.relationshipToParent !== "subagent", + !isNestedSubagentThread(shell), ); return rankSearchItems(candidates, (shell, options) => scoreThreadSearch(query, threadSearchSubject(shell, null), input.now, { diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 294a9baf6e9b6..48006a7bd1d6e 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -407,6 +407,8 @@ function simpleThreadCommand( | "thread.settle" | "thread.pin" | "thread.unpin" + | "thread.promote" + | "thread.demote" | "thread.guest-share.close", input: ThreadCommandInput, ) { @@ -533,6 +535,18 @@ export const unpinThread = Effect.fn("EnvironmentCommands.unpinThread")(function return yield* simpleThreadCommand("thread.unpin", input); }); +export const promoteThread = Effect.fn("EnvironmentCommands.promoteThread")(function* ( + input: ThreadCommandInput, +) { + return yield* simpleThreadCommand("thread.promote", input); +}); + +export const demoteThread = Effect.fn("EnvironmentCommands.demoteThread")(function* ( + input: ThreadCommandInput, +) { + return yield* simpleThreadCommand("thread.demote", input); +}); + export const unsettleThread = Effect.fn("EnvironmentCommands.unsettleThread")(function* ( input: UnsettleThreadInput, ) { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ac40471a853ba..39686bb88e182 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -179,6 +179,7 @@ export interface EnvironmentThreadShell { readonly snoozedAt: string | null; readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; + readonly promotedAt?: string | null; readonly autoSettleDisabledAt?: string | null; readonly pinOrderKey: string | null; readonly activeOrderKey: string | null; @@ -336,6 +337,7 @@ export function presentThreadShell( snoozedAt: nullableIso(thread.snoozedAt ?? null), limitRecovery: thread.limitRecovery ?? null, pinnedAt: nullableIso(thread.pinnedAt ?? null), + ...(thread.promotedAt == null ? {} : { promotedAt: iso(thread.promotedAt) }), autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null), pinOrderKey: thread.pinOrderKey ?? null, activeOrderKey: thread.activeOrderKey ?? null, diff --git a/packages/client-runtime/src/state/olderSettledThreads.ts b/packages/client-runtime/src/state/olderSettledThreads.ts index 86f7f86f0d1bd..d0bca62014cf9 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.ts @@ -1,4 +1,5 @@ import { + isNestedSubagentThread, isOlderSettledThreadShell, ORCHESTRATION_V2_WS_METHODS, type EnvironmentId, @@ -77,7 +78,7 @@ export function olderSettledCountKey( for (const sequence of removedAt.values()) lastRemoval = Math.max(lastRemoval, sequence); const rows: string[] = []; for (const thread of threads) { - if (thread.lineage.relationshipToParent === "subagent") continue; + if (isNestedSubagentThread(thread)) continue; const older = isOlderSettledThreadShell(thread, beforeMs); if (older || DateTime.toEpochMillis(thread.createdAt) < beforeMs) { rows.push(`${thread.id}:${older ? 1 : 0}`); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index fbba7d1bd9dd4..6a5c49214a35e 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -283,6 +283,8 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": + case "thread.promoted": + case "thread.demoted": case "thread.pin-reordered": case "thread.active-reordered": case "thread.metadata-updated": diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 0f34a926676c8..8601a1d1b29c1 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -25,6 +25,7 @@ import { cancelQueuedRun, createThread, deleteThread, + demoteThread, editQueuedRun, interruptThreadTurn, forkThreadFromRun, @@ -41,6 +42,7 @@ import { setThreadInteractionMode, setThreadRuntimeMode, pinThread, + promoteThread, reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, @@ -190,6 +192,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + promote: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:promote", + execute: promoteThread, + scheduler, + concurrency, + }), + demote: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:demote", + execute: demoteThread, + scheduler, + concurrency, + }), reorderPin: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:reorder-pin", execute: reorderPinnedThread, diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 15718da1eca65..dcc30896fcaa4 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -6,7 +6,11 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -import { isProviderNativeSubagentThread, OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import { + isNestedSubagentThread, + isProviderNativeSubagentThread, + OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Atom } from "effect/reactivity"; @@ -47,16 +51,16 @@ const isChildRunLive = ( /** * Whether a thread shows under its parent rather than in thread lists: a - * subagent, or a task a linked environment delegated here whose parent the + * subagent the user has not promoted, or a task a linked environment delegated here whose parent the * client lists (`delegatedUnderParent`, by thread key). A task whose parent * the client cannot list stays in the lists, so its work is never out of reach. */ export function isShownUnderParent( ref: ScopedThreadRef, - lineage: OrchestrationV2ThreadShell["lineage"], + thread: Parameters[0], delegatedUnderParent: ReadonlySet, ): boolean { - return lineage.relationshipToParent === "subagent" || delegatedUnderParent.has(threadKey(ref)); + return isNestedSubagentThread(thread) || delegatedUnderParent.has(threadKey(ref)); } /** Provider-native @@ -555,7 +559,7 @@ export function createEnvironmentThreadShellAtoms(input: { for (const thread of get(environmentThreadsAtom(environmentId))) { if ( thread.archivedAt !== null || - isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) + isShownUnderParent({ environmentId, threadId: thread.id }, thread, underParent) ) { continue; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 026e74831233d..f1fab4bfa6600 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -479,6 +479,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), guestShare: Schema.optional(Schema.NullOr(ThreadGuestShare)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** Set on a subagent child the user gave its own sidebar row. Lineage never changes. */ + promotedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -514,6 +516,14 @@ export function isProviderNativeSubagentThread( return thread.lineage.relationshipToParent === "subagent" && thread.creationSource === "provider"; } +/** A subagent child shown under its parent. A promoted one is listed as a thread of its own. */ +export function isNestedSubagentThread(thread: { + readonly lineage: Pick; + readonly promotedAt?: DateTime.Utc | string | null | undefined; +}): boolean { + return thread.lineage.relationshipToParent === "subagent" && thread.promotedAt == null; +} + export const IDLE_COMPACTION_TEXT = "/compact"; interface CompactionMessageFields { @@ -1842,6 +1852,8 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.pinned", "thread.auto-settle-set", "thread.unpinned", + "thread.promoted", + "thread.demoted", "thread.pin-reordered", "thread.active-reordered", "thread.visited", @@ -2093,6 +2105,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + promotedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -2142,11 +2155,14 @@ export function isLiveSubagentShell( * count every such thread, so the server and clients share this one rule. */ export function isOlderSettledThreadShell( - thread: Pick, + thread: Pick< + OrchestrationV2ThreadShell, + "lineage" | "promotedAt" | "settledOverride" | "settledAt" + >, beforeMs: number, ): boolean { return ( - thread.lineage.relationshipToParent !== "subagent" && + !isNestedSubagentThread(thread) && thread.settledOverride === "settled" && thread.settledAt !== null && DateTime.toEpochMillis(thread.settledAt) < beforeMs @@ -2240,6 +2256,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + promotedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( Schema.withDecodingDefault(Effect.succeed(null)), @@ -2674,6 +2691,7 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + promotedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), titleRegeneration: Schema.optional( @@ -2721,6 +2739,8 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.pinned", "thread.auto-settle-set", "thread.unpinned", + "thread.promoted", + "thread.demoted", "thread.pin-reordered", "thread.active-reordered", "thread.visited", @@ -2962,6 +2982,16 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, }), + Schema.Struct({ + type: Schema.Literal("thread.promote"), + commandId: CommandId, + threadId: ThreadId, + }), + Schema.Struct({ + type: Schema.Literal("thread.demote"), + commandId: CommandId, + threadId: ThreadId, + }), Schema.Struct({ type: Schema.Literal("thread.pin.reorder"), commandId: CommandId, From 1ceba58eb8b080ab31d04394d792f93b5af2936b Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 14:33:37 -0300 Subject: [PATCH 2/8] fix(web): hide the promote toggle for archived threads The server rejects promote and demote on archived threads, so the button could only fail. Co-Authored-By: Claude Fable 5.2 --- apps/web/src/components/chat/ThreadRelationshipsControl.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index a34e24dfd02df..7ce0d0c723e7d 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -588,7 +588,10 @@ export function ThreadRelationshipsPanel(props: { // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; - const canPromote = currentThread !== undefined && !isListedThreadLineage(currentThread.lineage); + const canPromote = + currentThread !== undefined && + currentThread.archivedAt === null && + !isListedThreadLineage(currentThread.lineage); const promoted = currentThread?.promotedAt != null; const lineageVisible = props.showLineage && From b5a02e86f3891255d2b08a133e3a8a51285fccb5 Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 14:35:47 -0300 Subject: [PATCH 3/8] fix(server): reject demote on threads that were never hidden children Co-Authored-By: Claude Fable 5.2 --- .../Orchestrator.control-reads.test.ts | 14 ++++++-------- apps/server/src/orchestration-v2/Orchestrator.ts | 5 ++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index d65fc65c00dc4..ce0b99acd3782 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1793,14 +1793,12 @@ it.effect("promoting a subagent thread lists it on its own without changing its assert.deepInclude(yield* projections.getThreadShell(childId), { lineage, promotedAt: null }); assert.include(yield* projections.listActiveSubagentThreadIds(), childId); - const rejected = yield* orchestrator - .dispatch({ - type: "thread.promote", - commandId: CommandId.make("promote-parent"), - threadId: parentId, - }) - .pipe(Effect.flip); - assert.include(String(rejected.cause), "not a subagent"); + for (const type of ["thread.promote", "thread.demote"] as const) { + const rejected = yield* orchestrator + .dispatch({ type, commandId: CommandId.make(`${type}-parent`), threadId: parentId }) + .pipe(Effect.flip); + assert.include(String(rejected.cause), "not a subagent"); + } }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a0a7b256e1880..56fb238ab64e7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3794,7 +3794,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } - if (command.type === "thread.promote" && isListedThreadLineage(thread.lineage)) { + if ( + (command.type === "thread.promote" || command.type === "thread.demote") && + isListedThreadLineage(thread.lineage) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, From 7145325a4df1d4eb3d6ffbc854544152128f7890 Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 15:00:34 -0300 Subject: [PATCH 4/8] fix(server): keep ancestors of promoted children and leave demoted side chats unfiltered Co-Authored-By: Claude Fable 5.2 --- .../src/orchestration-v2/ShellStream.test.ts | 15 +++++++++++++-- apps/server/src/orchestration-v2/ShellStream.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 11e680f45bd97..3a866a921351b 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -183,6 +183,9 @@ describe("omitIdleSubagentShells", () => { row("grandchild", "middle"), // Idle, but the user gave it a row of its own. row("promoted", "root", { promotedAt: DateTime.makeUnsafe(0) }), + // The idle parent of a promoted child stays, so approval rollups reach the root. + row("nested", "root"), + row("promotedUnderNested", "nested", { promotedAt: DateTime.makeUnsafe(0) }), ]; expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ "root", @@ -192,6 +195,8 @@ describe("omitIdleSubagentShells", () => { "background", "middle", "promoted", + "nested", + "promotedUnderNested", ]); }); @@ -209,15 +214,21 @@ describe("omitIdleSubagentShells", () => { it("publishes the transcript events of a child once it is promoted, until it is demoted", () => { const subagents = new Set(["child"]); - const publishes = (type: string) => + const publishes = (type: string, relationshipToParent: string = "subagent") => shouldPublishShellApplicationEvent( - { event: { threadId: "child", type } } as ApplicationStoredEvent, + { + event: { threadId: "child", type, payload: { lineage: { relationshipToParent } } }, + } as ApplicationStoredEvent, subagents, ); expect(publishes("thread.promoted")).toBe(true); expect(publishes("turn-item.updated")).toBe(true); expect(publishes("thread.demoted")).toBe(true); expect(publishes("turn-item.updated")).toBe(false); + // A demoted side chat is not a subagent, so its transcript events still refresh its shell. + publishes("thread.promoted"); + expect(publishes("thread.demoted", "fork")).toBe(true); + expect(publishes("turn-item.updated", "fork")).toBe(true); }); }); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index bc0a858d2c1ca..422473c2e71f5 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -97,9 +97,12 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { const subagentThreadIds = new Set(); const settledTimes: Array = []; for (const row of rows) { + // A promoted child still needs its idle ancestors, so parents are tracked by lineage. + if (row.lineage.relationshipToParent === "subagent" && row.lineage.parentThreadId) { + subagentParentIds.add(row.lineage.parentThreadId); + } if (isNestedSubagentThread(row)) { subagentThreadIds.add(row.id); - if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); } else if (isListedThread(row) && row.settledOverride === "settled" && row.settledAt !== null) { settledTimes.push(DateTime.toEpochMillis(row.settledAt)); } @@ -422,7 +425,9 @@ export function shouldPublishShellApplicationEvent( return true; } if (event.type === "thread.promoted") subagentThreadIds.delete(event.threadId); - if (event.type === "thread.demoted") subagentThreadIds.add(event.threadId); + if (event.type === "thread.demoted" && isNestedSubagentThread(event.payload)) { + subagentThreadIds.add(event.threadId); + } if (event.type === "thread.deleted") { // Replay and the live tail overlap; keep known IDs so late events stay filtered. return true; From 192b0028733b9826d2a5d5ff1ec898ec0c16bbd8 Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 15:14:48 -0300 Subject: [PATCH 5/8] fix(server): keep older settled parents of promoted children in trimmed snapshots Co-Authored-By: Claude Fable 5.2 --- .../server/src/orchestration-v2/ShellStream.test.ts | 13 ++++++++++++- apps/server/src/orchestration-v2/ShellStream.ts | 6 ++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 3a866a921351b..0ec4e9067c11a 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -322,6 +322,15 @@ describe("omitOlderSettledShells", () => { settledOverride: null, settledAt: null, }), + row("promoted-parent", 30), + row("promoted-child", null, { + promotedAt: DateTime.makeUnsafe(0), + lineage: { + parentThreadId: ThreadId.make("promoted-parent"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("promoted-parent"), + }, + }), row("side-chat-parent", 30), row("side-chat", null, { lineage: { @@ -338,10 +347,12 @@ describe("omitOlderSettledShells", () => { "running", "parent", "child", + "promoted-parent", + "promoted-child", "side-chat-parent", "side-chat", ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 6 }]); }); }); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 422473c2e71f5..b76061c48b81e 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { isListedThread, + isListedThreadLineage, isLiveSubagentShell, isNestedSubagentThread, isOlderSettledThreadShell, @@ -131,7 +132,8 @@ function hiddenChildAncestorIds( ) { const ancestorIds = new Set(); for (const thread of threads) { - if (isListedThread(thread)) continue; + // By lineage, so a promoted child still keeps its older settled ancestors. + if (isListedThreadLineage(thread.lineage)) continue; if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); ancestorIds.add(thread.lineage.rootThreadId); } @@ -265,7 +267,7 @@ function shellSubscriberThreadIds( ); const ancestorIds = hiddenChildAncestorIds([ ...subagents, - ...index.filter((entry) => entry.lineage.sideChat === true), + ...index.filter((entry) => entry.lineage.sideChat === true || entry.promotedAt != null), ]); const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); const beforeMs = facts.olderSettledBeforeMs; From 0ca70e79d49d7dcd64d5265a36ac28eb78d641dc Mon Sep 17 00:00:00 2001 From: c101639 Date: Wed, 11 Nov 2026 15:29:29 -0300 Subject: [PATCH 6/8] test(server): cover indexed shell selection for promoted children and their older settled parents Co-Authored-By: Claude Fable 5.2 --- .../src/orchestration-v2/ShellStream.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 0ec4e9067c11a..554bf197d9593 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -21,6 +21,7 @@ import { bufferShellLiveStream, composeShellStreamWithEnrichment, dedupeShellEnrichment, + loadSubscriberShellSnapshot, omitIdleSubagentShells, omitOlderSettledShells, shellStreamItemFromEnrichmentRefresh, @@ -356,6 +357,77 @@ describe("omitOlderSettledShells", () => { }); }); +describe("loadSubscriberShellSnapshot", () => { + it.effect( + "keeps an idle promoted child and its older settled parent through indexed selection", + () => + Effect.gen(function* () { + const DAY = 24 * 60 * 60 * 1000; + const settled = (daysAgo: number) => ({ + settledOverride: "settled" as const, + settledAt: DateTime.makeUnsafe(100 * DAY - daysAgo * DAY), + }); + const top = (id: string, daysAgo: number) => + shellFixture({ + id: ThreadId.make(id), + projectId: ProjectId.make("project-a"), + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: ThreadId.make(id), + }, + ...settled(daysAgo), + }); + const threads = [ + ...Array.from({ length: 25 }, (_, index) => top(`new-${index}`, index / 10)), + top("old", 30), + top("old-parent", 30), + shellFixture({ + id: ThreadId.make("promoted-child"), + projectId: ProjectId.make("project-a"), + lineage: { + parentThreadId: ThreadId.make("old-parent"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("old-parent"), + }, + promotedAt: DateTime.makeUnsafe(0), + settledOverride: null, + settledAt: null, + }), + ]; + const index = threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + lineage: thread.lineage, + promotedAt: thread.promotedAt ?? null, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + pinnedAt: null, + snoozedUntil: null, + mayBeLive: false, + })); + const loaded = yield* loadSubscriberShellSnapshot({ + sql: { withTransaction: (effect: Effect.Effect) => effect } as never, + listShellIndex: Effect.succeed(index), + readThreads: (threadIds) => + Effect.succeed( + Effect.succeed({ + ...emptyShellSnapshot, + threads: threads.filter((thread) => threadIds?.includes(thread.id) ?? true), + }), + ), + listProjects: Effect.succeed([]), + latestSequence: Effect.succeed(7), + options: { omitIdleSubagents: true, omitOlderSettled: true }, + }); + const ids = loaded.snapshot.threads.map((thread) => thread.id); + expect(ids).toContain("promoted-child"); + expect(ids).toContain("old-parent"); + expect(ids).not.toContain("old"); + }), + ); +}); + describe("shellStreamItemsFromInitialSnapshot", () => { it("keeps thread rows out of the metadata-only enrichment frame", () => { const snapshot = { From b371868e62ce8f6a69ed5ed4066ce44f964dbcd7 Mon Sep 17 00:00:00 2001 From: Maria Date: Wed, 11 Nov 2026 20:22:50 +0000 Subject: [PATCH 7/8] fix(web): allow promotion from the side chat pane --- apps/web/src/components/chat/SideChatPane.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx index 2f430e563925e..35cece7416835 100644 --- a/apps/web/src/components/chat/SideChatPane.tsx +++ b/apps/web/src/components/chat/SideChatPane.tsx @@ -11,7 +11,7 @@ import { type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; -import { Trash2Icon, XIcon } from "lucide-react"; +import { ArrowUpRightIcon, CornerDownRightIcon, Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -53,9 +53,13 @@ export default function SideChatPane(props: { const canOperate = useEnvironmentScope(parentRef.environmentId, AuthOrchestrationOperateScope); const mergeBack = useAtomCommand(threadEnvironment.mergeBack, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const promoteThread = useAtomCommand(threadEnvironment.promote, { reportFailure: false }); + const demoteThread = useAtomCommand(threadEnvironment.demote, { reportFailure: false }); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const [bringingBack, setBringingBack] = useState(false); const [deleting, setDeleting] = useState(false); + const [promoting, setPromoting] = useState(false); + const promoted = shell?.promotedAt != null; const mergeRun = projection ? resolveLatestMergeBackRun(projection) : null; // A pending merge-back would fail the main thread's queued message, so the server refuses it. const bringBackBlockedReason = !canOperate @@ -90,6 +94,24 @@ export default function SideChatPane(props: { } }; + const togglePromoted = async () => { + if (!canOperate || shell === null || shell.archivedAt !== null || promoting) return; + setPromoting(true); + const result = await (promoted ? demoteThread : promoteThread)({ + environmentId: parentRef.environmentId, + input: { threadId }, + }); + setPromoting(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: promoted + ? "Could not move thread back under its parent" + : "Could not promote thread", + }); + } + }; + const deleteSideChat = async () => { if (!canOperate || shell === null || deleting) return; if (confirmThreadDelete) { @@ -132,6 +154,28 @@ export default function SideChatPane(props: { {shell?.title ?? "Side chat"} + {shell?.archivedAt === null ? ( + + void togglePromoted()} + /> + } + > + {promoted ? : } + + + {promoted + ? "Hide this thread from the sidebar again; it stays under its parent" + : "Give this chat its own row in the sidebar"} + + + ) : null} Date: Wed, 11 Nov 2026 20:48:19 +0000 Subject: [PATCH 8/8] fix: reset fork promotion and respect read-only connections --- apps/server/src/orchestration-v2/ThreadForkService.test.ts | 2 ++ apps/server/src/orchestration-v2/ThreadForkService.ts | 1 + .../web/src/components/chat/ThreadRelationshipsControl.tsx | 7 +++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index f9f6ff25b2f0a..efb46291f82db 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -141,6 +141,7 @@ it.effect("resets a fork's pin, snooze, settle and delegation state but keeps it ...makeSourceThread(), lastSnoozeWakeAt: snoozedAt, pinnedAt: snoozedAt, + promotedAt: snoozedAt, pinOrderKey: "a0", activeOrderKey: "a1", autoSettleDisabledAt: snoozedAt, @@ -163,6 +164,7 @@ it.effect("resets a fork's pin, snooze, settle and delegation state but keeps it assert.isNull(result.targetThread.snoozedAt); assert.isNull(result.targetThread.lastSnoozeWakeAt); assert.isNull(result.targetThread.pinnedAt); + assert.isNull(result.targetThread.promotedAt); assert.isNull(result.targetThread.pinOrderKey); assert.isNull(result.targetThread.activeOrderKey); assert.isNull(result.targetThread.autoSettleDisabledAt); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 1dc6d1eaf4a54..11fc8cdfb8442 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -133,6 +133,7 @@ export const layer: Layer.Layer = Layer.succeed( snoozedAt: null, lastSnoozeWakeAt: null, pinnedAt: null, + promotedAt: null, pinOrderKey: null, activeOrderKey: null, autoSettleDisabledAt: null, diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 7ce0d0c723e7d..3b449e3f0e4df 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -29,6 +29,7 @@ import { resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; import { + AuthOrchestrationOperateScope, isOrchestrationV2WorkActive, isListedThreadLineage, isProviderNativeSubagentThread, @@ -72,6 +73,7 @@ import { useAnyEnvironmentHiding, } from "../../state/entities"; import { useEnvironment, useEnvironmentProviders } from "../../state/environments"; +import { useEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; import { applyProviderInstanceSettings, @@ -438,6 +440,7 @@ export function ThreadRelationshipsPanel(props: { const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); const promoteThread = useAtomCommand(threadEnvironment.promote); const demoteThread = useAtomCommand(threadEnvironment.demote); + const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); const canOperateWorkflow = useAtomValue( orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), @@ -668,7 +671,7 @@ export function ThreadRelationshipsPanel(props: { }; const togglePromoted = async () => { - if (busyAction !== null) return; + if (!canOperate || busyAction !== null) return; setBusyAction("promote"); const result = await (promoted ? demoteThread : promoteThread)({ environmentId: props.environmentId, @@ -998,7 +1001,7 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" part="icon" aria-label={promoted ? "Move back under parent" : "Promote to thread"} - disabled={busyAction !== null} + disabled={!canOperate || busyAction !== null} onClick={() => void togglePromoted()} /> }