diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 38b83e4c56bad..bc771aa4c5f2e 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -9,7 +9,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThread, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -426,7 +426,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) + .filter((thread) => thread.archivedAt === null && isListedThread(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 7854f9d7fdb85..0ff4cd8fa4fa6 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -29,7 +29,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; +import { isListedThread, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import type { MenuAction } from "@react-native-menu/menu"; @@ -324,7 +324,7 @@ export function getThreadListV2OrderedSection(input: { }): EnvironmentThreadShell[] { const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; const threads = input.threads.filter((thread) => { - if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) return false; + if (thread.archivedAt !== null || !isListedThread(thread)) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -705,7 +705,7 @@ export function buildThreadListV2Items(input: { const listedThreads = input.threads.filter( (thread) => thread.archivedAt === null && - isListedThreadLineage(thread.lineage) && + isListedThread(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 f36ce5bdc564b..f204844998ead 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -2007,6 +2007,73 @@ 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); + + 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)), +); + it.effect("keeps a snooze when a live question is cancelled or an approval is answered", () => 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 dfcf279a0392e..fc697c86a9e4e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -27,6 +27,7 @@ import { type GuestMessageId, isCompactionCommandMessage, isOrchestrationV2WorkActive, + isListedThreadLineage, isProviderNativeSubagentThread, MessageId, type NodeId, @@ -629,6 +630,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.tag.set": @@ -4009,6 +4012,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.tag.set" | "thread.active.reorder" @@ -4177,6 +4182,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") && @@ -4188,6 +4195,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } + if ( + (command.type === "thread.promote" || command.type === "thread.demote") && + isListedThreadLineage(thread.lineage) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is not a subagent or side chat 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({ @@ -4636,6 +4653,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 { @@ -5075,6 +5100,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": @@ -14749,6 +14778,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.tag.set": diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 78422b56535af..440602a8d310f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -214,6 +214,7 @@ export type ProjectionSettlementCandidate = Pick< | "archivedAt" | "settledOverride" | "pinnedAt" + | "promotedAt" | "autoSettleDisabledAt" | "snoozedUntil" | "snoozedAt" @@ -459,7 +460,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; @@ -901,6 +909,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": @@ -1810,6 +1820,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, @@ -2107,6 +2118,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, @@ -2269,6 +2281,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.tags-updated": @@ -3086,6 +3100,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.tags-updated" && @@ -8124,6 +8140,7 @@ export const layer: Layer.Layer = return { ...thread, pinnedAt: thread.pinnedAt ?? null, + promotedAt: thread.promotedAt ?? null, autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, @@ -8681,6 +8698,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)); }, @@ -8705,6 +8723,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; readonly has_remote_tasks: number; @@ -8719,6 +8738,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. @@ -8786,6 +8806,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, hasRemoteTasks: row.has_remote_tasks === 1, @@ -8844,8 +8865,9 @@ 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.sideChat') IS NOT 1 + AND (json_extract(t.payload_json, '$.promotedAt') IS NOT NULL + OR (json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1)) 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 fbc610235dd01..97273f61b23c0 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, @@ -187,6 +188,11 @@ 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) }), + // 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", @@ -195,6 +201,9 @@ describe("omitIdleSubagentShells", () => { "asking", "background", "middle", + "promoted", + "nested", + "promotedUnderNested", ]); }); @@ -215,6 +224,25 @@ 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, relationshipToParent: string = "subagent") => + shouldPublishShellApplicationEvent( + { + 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); + }); }); describe("omitOlderSettledShells", () => { @@ -307,6 +335,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: { @@ -323,13 +360,87 @@ 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 }]); }); }); +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, + hasRemoteTasks: 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 = { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 5ee1005b16830..c363d2566b477 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -12,8 +12,10 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + isListedThread, isListedThreadLineage, isLiveSubagentShell, + isNestedSubagentThread, isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, OrchestrationV2ThreadShell as ThreadShellSchema, @@ -179,7 +181,7 @@ function buildActiveShellSnapshot(input: { type ShellTrimRow = Pick< OrchestrationV2ThreadShell, - "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" + "id" | "projectId" | "lineage" | "promotedAt" | "settledOverride" | "settledAt" > & { readonly hasRemoteTasks?: boolean }; /** @@ -205,14 +207,13 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { const settledTimes: Array = []; for (const row of rows) { if (row.hasRemoteTasks === true) remoteTaskParentIds.add(row.id); - if (row.lineage.relationshipToParent === "subagent") { + // 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 ( - isListedThreadLineage(row.lineage) && - row.settledOverride === "settled" && - row.settledAt !== null - ) { + } else if (isListedThread(row) && row.settledOverride === "settled" && row.settledAt !== null) { settledTimes.push(DateTime.toEpochMillis(row.settledAt)); } } @@ -237,10 +238,11 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { } function hiddenChildAncestorIds( - threads: ReadonlyArray>, + threads: ReadonlyArray>, ) { const ancestorIds = new Set(); for (const thread of threads) { + // 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); @@ -254,7 +256,7 @@ function omitIdleSubagentShellsWith( ): ReadonlyArray { return threads.filter( (thread) => - thread.lineage.relationshipToParent !== "subagent" || + !isNestedSubagentThread(thread) || isLiveSubagentShell(thread) || facts.subagentParentIds.has(thread.id) || facts.remoteTaskParentIds.has(thread.id), @@ -376,7 +378,7 @@ function shellSubscriberThreadIds( const facts = shellTrimFacts(index); const subagents = index.filter( (entry) => - entry.lineage.relationshipToParent === "subagent" && + isNestedSubagentThread(entry) && (!options.omitIdleSubagents || entry.mayBeLive || entry.hasRemoteTasks || @@ -384,12 +386,12 @@ 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; return index.flatMap((entry) => - entry.lineage.relationshipToParent === "subagent" + isNestedSubagentThread(entry) ? keptSubagentIds.has(entry.id) ? [entry.id] : [] @@ -539,11 +541,15 @@ 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" && 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; 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/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0d428e1a6e1e0..e55deb6a82f3a 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -152,6 +152,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 7f60c1d35215a..627285506f01c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -17,7 +17,7 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import { - isListedThreadLineage, + isListedThread, type ContextMenuItem, type EnvironmentId, type ResolvedKeybindingsConfig, @@ -581,8 +581,10 @@ export async function deleteSelectedThreadEntries< return { deletedThreadKeys, firstFailure }; } -export function isSidebarHiddenThread(thread: Pick): boolean { - return !isListedThreadLineage(thread.lineage); +export function isSidebarHiddenThread( + thread: Pick, +): boolean { + return !isListedThread(thread); } // Subagent and side-chat threads have no sidebar row, so an open one is represented by @@ -591,7 +593,7 @@ export function isSidebarHiddenThread(thread: Pick[], + threads: readonly Pick[], ): ThreadId { const findThread = (id: ThreadId) => threads.find((thread) => thread.id === id && thread.environmentId === environmentId); @@ -610,7 +612,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; }, >( @@ -623,7 +628,7 @@ export function filterSidebarV2VisibleThreads< thread.archivedAt === null && !isShownUnderParent( { environmentId: thread.environmentId, threadId: thread.id }, - thread.lineage, + thread, delegatedUnderParent, ) && (scopedProjectKeys === null || @@ -1430,7 +1435,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 6e6b2b37b6830..5efe14e7b0264 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -246,7 +246,7 @@ export function startThreadNotifications( if ( isShownUnderParent( { environmentId, threadId: rawThread.id }, - rawThread.lineage, + rawThread, delegatedUnderParent, ) ) { 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} (null); + const [busyAction, setBusyAction] = useState<"merge" | "detach" | "promote" | null>(null); const mergeErrorToastId = useRef(null); const detachErrorToastId = useRef(null); const [stoppingId, setStoppingId] = useState(null); @@ -610,6 +618,11 @@ export function ThreadRelationshipsPanel(props: { // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; + const canPromote = + currentThread !== undefined && + currentThread.archivedAt === null && + !isListedThreadLineage(currentThread.lineage); + const promoted = currentThread?.promotedAt != null; const lineageVisible = props.showLineage && (related.length > 0 || @@ -620,7 +633,8 @@ export function ThreadRelationshipsPanel(props: { remotePrevious.length > 0 || delegatedFrom !== undefined || // Disconnect lives in this heading, so a thread with only side chats still shows it. - (canDetach && sideChats.length > 0)); + (canDetach && sideChats.length > 0) || + canPromote); const sideChatsVisible = props.showSideChats && sideChats.length > 0; const runningSideChats = sideChats.filter(({ edge }) => edge.status === "running").length; @@ -683,6 +697,24 @@ export function ThreadRelationshipsPanel(props: { } }; + const togglePromoted = async () => { + if (!canOperate || 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, @@ -977,6 +1009,7 @@ export function ThreadRelationshipsPanel(props: { showHeading={ runningCount > 0 || canDetach || + canPromote || related.length > 0 || active.length > 0 || remoteActive.length > 0 || @@ -984,28 +1017,61 @@ export function ThreadRelationshipsPanel(props: { } data-thread-relationships-panel actions={ - canDetach ? ( - - - } - > - - - - void detach()}> - - Disconnect agent session - - - + canPromote || canDetach ? ( + <> + {canPromote ? ( + + void togglePromoted()} + /> + } + > + {busyAction === "promote" ? ( + + ) : promoted ? ( + + ) : ( + + )} + + + {promoted + ? "Hide this thread from the sidebar again; it stays under its parent" + : "Give this chat 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 32adee581a6e9..d1d3fd6af417a 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -1,5 +1,5 @@ import { - isListedThreadLineage, + isListedThread, type EnvironmentId, type EnvironmentMachineKind, type ScopedThreadRef, @@ -47,7 +47,7 @@ export function matchComposerThreadItems(input: { input.linkedEnvironments.has(shell.environmentId)) && shell.id !== input.excludeThreadId && shell.archivedAt === null && - isListedThreadLineage(shell.lineage), + isListedThread(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 c6f5c8c34166e..6978cdd31b4fe 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -490,6 +490,8 @@ function simpleThreadCommand( | "thread.settle" | "thread.pin" | "thread.unpin" + | "thread.promote" + | "thread.demote" | "thread.guest-share.close", input: ThreadCommandInput, ) { @@ -621,6 +623,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 6bc914b8572fd..72a3840cb68fe 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -186,6 +186,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; @@ -341,6 +342,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 47779f110efdc..1b9ddffc9e355 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.ts @@ -1,5 +1,5 @@ import { - isListedThreadLineage, + isListedThread, isOlderSettledThreadShell, ORCHESTRATION_V2_WS_METHODS, type EnvironmentId, @@ -93,7 +93,7 @@ export function olderSettledCountKey( for (const sequence of removedAt.values()) lastRemoval = Math.max(lastRemoval, sequence); const rows: string[] = []; for (const thread of threads) { - if (!isListedThreadLineage(thread.lineage)) continue; + if (!isListedThread(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 76d1c9d6b9c15..3a92149e8a3de 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -307,6 +307,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 865f6273b20ad..d06d7d8ca50ba 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -30,6 +30,7 @@ import { cancelQueuedRun, createThread, deleteThread, + demoteThread, editQueuedRun, interruptThreadTurn, forkSideChat, @@ -47,6 +48,7 @@ import { setThreadInteractionMode, setThreadRuntimeMode, pinThread, + promoteThread, reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, @@ -232,6 +234,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 3aaaab30720ad..312bc3fc716ed 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,7 +7,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { - isListedThreadLineage, + isListedThread, isProviderNativeSubagentThread, OrchestrationV2ThreadShell, } from "@t3tools/contracts"; @@ -57,16 +57,16 @@ const isChildRunLive = ( /** * Whether a thread shows under its parent rather than in thread lists: a - * subagent, a side chat, or a task a linked environment delegated here whose parent the + * subagent or side chat 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 !isListedThreadLineage(lineage) || delegatedUnderParent.has(threadKey(ref)); + return !isListedThread(thread) || delegatedUnderParent.has(threadKey(ref)); } /** Provider-native @@ -712,7 +712,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) || hidden.has(threadKey({ environmentId, threadId: thread.id })) ) { continue; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 69338865e55a6..c46c6322c2765 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -509,6 +509,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)), @@ -572,6 +574,22 @@ export function isProviderNativeSubagentThread( return thread.lineage.relationshipToParent === "subagent" && thread.creationSource === "provider"; } +/** Hidden children (subagents, side chats) are listed once the user promotes them. */ +export function isListedThread(thread: { + readonly lineage: OrchestrationV2AppThreadLineage; + readonly promotedAt?: DateTime.Utc | string | null | undefined; +}): boolean { + return isListedThreadLineage(thread.lineage) || thread.promotedAt != null; +} + +/** A subagent child shown under its parent until the user promotes it. */ +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 { @@ -1973,6 +1991,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.tags-updated", @@ -2234,6 +2254,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)), @@ -2286,11 +2307,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 ( - isListedThreadLineage(thread.lineage) && + isListedThread(thread) && thread.settledOverride === "settled" && thread.settledAt !== null && DateTime.toEpochMillis(thread.settledAt) < beforeMs @@ -2407,6 +2431,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)), @@ -2861,6 +2886,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( @@ -2916,6 +2942,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.tags-updated", @@ -3166,6 +3194,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.tag.set"), commandId: CommandId,