From 48988d3603f0d008d028d2d15da0adf3dbf7ac00 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 20:09:43 -0700 Subject: [PATCH 01/13] feat(web): regenerate thread titles from sidebar --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 322 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 134 +++++++- apps/server/src/orchestration/decider.ts | 6 + .../textGeneration/ClaudeTextGeneration.ts | 1 + .../src/textGeneration/CodexTextGeneration.ts | 1 + .../textGeneration/CursorTextGeneration.ts | 1 + .../src/textGeneration/GrokTextGeneration.ts | 1 + .../textGeneration/OpenCodeTextGeneration.ts | 1 + .../src/textGeneration/TextGeneration.ts | 6 +- .../TextGenerationPrompts.test.ts | 39 +++ .../textGeneration/TextGenerationPrompts.ts | 43 ++- apps/web/src/components/SidebarV2.tsx | 24 ++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.test.ts | 33 ++ packages/contracts/src/orchestration.ts | 14 +- 17 files changed, 622 insertions(+), 9 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index e9dbc4a5956..a7aea90f826 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.threadTitleRegeneration).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a..250d4d7745a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..945bd32df98 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -387,6 +387,7 @@ describe("ProviderCommandReactor", () => { const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -434,6 +435,7 @@ describe("ProviderCommandReactor", () => { runtimeSessions, stateDir, drain, + runEffect, }; } @@ -637,6 +639,326 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + it("regenerates a thread title from the current conversation", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Resolve stale reconnect state" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-existing"), + threadId: ThreadId.make("thread-1"), + title: "Investigate reconnect regressions", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-title-regeneration"), + role: "user", + text: "Please investigate reconnect regressions after restarting the session.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-assistant-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-title-regeneration"), + delta: "The remaining issue is stale reconnect state.", + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-assistant-complete-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-title-regeneration"), + createdAt: "2026-01-01T00:00:02.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regenerate"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-project", + previousTitle: "Investigate reconnect regressions", + message: [ + "USER:", + "Please investigate reconnect regressions after restarting the session.", + "", + "ASSISTANT:", + "The remaining issue is stale reconnect state.", + ].join("\n"), + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Resolve stale reconnect state"); + }); + + it("keeps the current title when regeneration returns the fallback", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "New thread" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep meaningful title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-fallback-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep meaningful title"); + }); + + it("keeps the full retained context and excludes attachments outside it", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const retainedContext = "x".repeat(8_000); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-truncated-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Existing title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-truncated-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-truncated-regeneration"), + role: "user", + text: "Old visual issue", + attachments: [ + { + type: "image", + id: "old-title-context-image", + name: "old-issue.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-assistant-truncated-regeneration-context"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-truncated-regeneration-context"), + delta: `content before retained tail${"x".repeat(8_100)}`, + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-assistant-truncated-regeneration-context-complete"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-truncated-regeneration-context"), + createdAt: "2026-01-01T00:00:02.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regenerate-truncated-context"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe( + `[Earlier content truncated]\n\n${retainedContext}`, + ); + expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined(); + }); + + it("does not overwrite a manual rename while title regeneration is running", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const generatedTitle = await harness.runEffect( + Deferred.make<{ readonly title: string }, never>(), + ); + harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle)); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-regeneration-race"), + threadId: ThreadId.make("thread-1"), + title: "Existing thread title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-regeneration-race"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-regeneration-race"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regeneration-race"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-manual-rename-during-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep manual rename", + }), + ); + await harness.runEffect( + Deferred.succeed(generatedTitle, { title: "Generated title should not win" }), + ); + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep manual rename"); + }); + + it("does not overwrite a manual rename while title regeneration is queued", async () => { + let releaseStart = () => {}; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const harness = await createHarness({ + startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)), + }); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Generated title should not win" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Existing thread title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-queued-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-manual-rename-before-regeneration-starts"), + threadId: ThreadId.make("thread-1"), + title: "Keep queued manual rename", + }), + ); + releaseStart(); + await harness.drain(); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep queued manual rename"); + }); + it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d..cbde7ccf29c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -51,6 +51,7 @@ type ProviderIntentEvent = Extract< OrchestrationEvent, { type: + | "thread.meta-updated" | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" @@ -90,6 +91,60 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +const MAX_REGENERATION_ATTACHMENTS = 4; +const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; +const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; + +function formatThreadTitleContext( + messages: ReadonlyArray<{ + readonly role: "user" | "assistant" | "system"; + readonly text: string; + readonly attachments?: ReadonlyArray | undefined; + }>, +): { + readonly message: string; + readonly attachments: ReadonlyArray; +} { + let context = ""; + let truncated = false; + const retainedAttachments: Array = []; + + for (const message of messages.toReversed()) { + if (message.role === "system") { + continue; + } + const text = message.text.trim(); + const attachmentSummary = (message.attachments ?? []) + .map((attachment) => attachment.name) + .join(", "); + const contents = [ + ...(text.length > 0 ? [text] : []), + ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []), + ].join("\n"); + if (contents.length === 0) { + continue; + } + + const section = `${message.role.toUpperCase()}:\n${contents}`; + const separator = context.length > 0 ? "\n\n" : ""; + const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length; + if (section.length > available) { + if (available > 0) { + context = `${section.slice(-available)}${separator}${context}`; + retainedAttachments.unshift(...(message.attachments ?? [])); + } + truncated = true; + break; + } + context = `${section}${separator}${context}`; + retainedAttachments.unshift(...(message.attachments ?? [])); + } + + return { + message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context, + attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS), + }; +} export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -777,6 +832,76 @@ const make = Effect.gen(function* () { }, ); + const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* ( + event: Extract, + ) { + if (event.payload.regenerateTitle !== true) { + return; + } + + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + + const { message, attachments } = formatThreadTitleContext(thread.messages); + if (message.length === 0) { + return; + } + + const previousTitle = event.payload.previousTitle ?? thread.title; + if (thread.title !== previousTitle) { + return; + } + const project = yield* resolveProject(thread.projectId); + const cwd = + resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }) ?? process.cwd(); + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + const generated = yield* textGeneration.generateThreadTitle({ + cwd, + message, + previousTitle, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }); + if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) { + return; + } + + const latestThread = yield* resolveThread(event.payload.threadId); + if (!latestThread || latestThread.title !== previousTitle) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("thread-title-regenerate"), + threadId: event.payload.threadId, + title: generated.title, + }); + }); + const processThreadTitleRegenerationSafely = ( + event: Extract, + ) => + regenerateThreadTitle(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + const threadTitleRegenerationWorker = yield* makeDrainableWorker( + processThreadTitleRegenerationSafely, + ); + const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( event: Extract, ) { @@ -1047,6 +1172,9 @@ const make = Effect.gen(function* () { eventType: event.type, }); switch (event.type) { + case "thread.meta-updated": + yield* threadTitleRegenerationWorker.enqueue(event); + return; case "thread.runtime-mode-set": { const thread = yield* resolveThread(event.payload.threadId); if (!thread?.session || thread.session.status === "stopped") { @@ -1096,6 +1224,7 @@ const make = Effect.gen(function* () { const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( + (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || event.type === "thread.runtime-mode-set" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || @@ -1114,7 +1243,10 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: Effect.gen(function* () { + yield* worker.drain; + yield* threadTitleRegenerationWorker.drain; + }), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..e5e3f84156e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -654,6 +654,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.regenerateTitle === true + ? { + regenerateTitle: true as const, + previousTitle: thread.title, + } + : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index a27b2c8bd3e..37304795a22 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -342,6 +342,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ea710cd5a3..62d917012ae 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -384,6 +384,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index be0fc858ac5..5ae057b54f6 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -242,6 +242,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index d26a2ebe01b..1cf3d13e225 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -234,6 +234,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 2adbb829b8d..e09c3db2cff 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -598,6 +598,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); const generated = yield* runOpenCodeJson({ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index ead09638776..66b7ccd465f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -62,6 +62,8 @@ export interface BranchNameGenerationResult { export interface ThreadTitleGenerationInput { cwd: string; message: string; + /** Present when replacing an existing title from the current thread history. */ + previousTitle?: string | undefined; attachments?: ReadonlyArray | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; @@ -107,9 +109,7 @@ export class TextGeneration extends Context.Service< input: BranchNameGenerationInput, ) => Effect.Effect; - /** - * Generate a concise thread title from a user's first message. - */ + /** Generate a concise thread title from a first message or thread history. */ readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 65b61b99bfc..209bbf3cc79 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -175,6 +175,45 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("image/png"); expect(result.prompt).toContain("67890 bytes"); }); + + it("regenerates from recent thread contents and identifies the previous title", () => { + const result = buildThreadTitlePrompt({ + message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, + previousTitle: "Investigate reconnect regressions", + }); + + expect(result.prompt).toContain( + "The user requested a new title based on the contents of this thread.", + ); + expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); + expect(result.prompt).toContain("better represents the current state of the thread"); + expect(result.prompt).toContain("Thread contents:"); + expect(result.prompt).toContain("The remaining issue is stale session state"); + }); + + it("keeps the latest thread contents when regeneration context is truncated", () => { + const result = buildThreadTitlePrompt({ + message: `${"old context ".repeat(1_000)}\n\nASSISTANT:\nCurrent thread state`, + previousTitle: "Old title", + }); + + expect(result.prompt).toContain("[Earlier content truncated]"); + expect(result.prompt).toContain("Current thread state"); + expect(result.prompt).not.toContain("[truncated]"); + }); + + it("does not truncate an already-marked regeneration context twice", () => { + const retainedContext = "x".repeat(7_998); + const result = buildThreadTitlePrompt({ + message: `[Earlier content truncated]\n\n${retainedContext}`, + previousTitle: "Old title", + }); + + expect(result.prompt).toContain( + `Thread contents:\n[Earlier content truncated]\n\n${retainedContext}`, + ); + expect(result.prompt.match(/\[Earlier content truncated\]/g)).toHaveLength(1); + }); }); describe("sanitizeThreadTitle", () => { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index efa251963a5..b7968c4797c 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -12,6 +12,8 @@ import type { ChatAttachment } from "@t3tools/contracts"; import { limitSection } from "./TextGenerationUtils.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; +const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; + function policyInstruction(instruction: string | undefined): ReadonlyArray { const trimmed = instruction?.trim(); return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; @@ -150,10 +152,23 @@ interface PromptFromMessageInput { responseShape: string; rules: ReadonlyArray; message: string; + messageLabel?: string | undefined; + preserveMessageEnd?: boolean | undefined; attachments?: ReadonlyArray | undefined; additionalInstructions?: string | undefined; } +function preserveMessageEnd(message: string): string { + const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER); + const contents = alreadyTruncated + ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length) + : message; + if (!alreadyTruncated && contents.length <= 8_000) { + return contents; + } + return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`; +} + function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, @@ -165,8 +180,10 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { "Rules:", ...input.rules.map((rule) => `- ${rule}`), "", - "User message:", - limitSection(input.message, 8_000), + `${input.messageLabel ?? "User message"}:`, + input.preserveMessageEnd + ? preserveMessageEnd(input.message) + : limitSection(input.message, 8_000), ...policyInstruction(input.additionalInstructions), ]; if (attachmentLines.length > 0) { @@ -207,21 +224,39 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { export interface ThreadTitlePromptInput { message: string; + previousTitle?: string | undefined; attachments?: ReadonlyArray | undefined; policy?: TextGenerationPolicy | undefined; } export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { + const isRegeneration = input.previousTitle !== undefined; const prompt = buildPromptFromMessage({ - instruction: "You write concise thread titles for coding conversations.", + instruction: isRegeneration + ? [ + "You write concise thread titles for coding conversations.", + "The user requested a new title based on the contents of this thread.", + `The previous title was ${JSON.stringify(input.previousTitle)}.`, + "Come up with a new title that better represents the current state of the thread.", + ].join("\n") + : "You write concise thread titles for coding conversations.", responseShape: "Return a JSON object with key: title.", rules: [ - "Title should summarize the user's request, not restate it verbatim.", + isRegeneration + ? "Title should summarize the thread's current state, not just its initial request." + : "Title should summarize the user's request, not restate it verbatim.", + ...(isRegeneration ? ["Return a different title from the previous title."] : []), "Keep it short and specific (3-8 words).", "Avoid quotes, filler, prefixes, and trailing punctuation.", "If images are attached, use them as primary context for visual/UI issues.", ], message: input.message, + ...(isRegeneration + ? { + messageLabel: "Thread contents", + preserveMessageEnd: true, + } + : {}), attachments: input.attachments, additionalInstructions: input.policy?.threadTitleInstructions, }); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 33c2512abf7..258240891cb 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2063,6 +2063,9 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsTitleRegeneration = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2101,6 +2104,9 @@ export default function SidebarV2() { ] : []), { id: "rename", label: "Rename thread" }, + ...(supportsTitleRegeneration + ? [{ id: "regenerate-title", label: "Regenerate title" }] + : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy path", icon: "copy" }, ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), @@ -2153,6 +2159,23 @@ export default function SidebarV2() { case "rename": startThreadRename(threadRef, thread.title); return; + case "regenerate-title": { + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2219,6 +2242,7 @@ export default function SidebarV2() { projectCwdByKey, serverConfigs, startThreadRename, + updateThreadMetadata, ], ); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c16541..3d753350d99 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands regenerateTitle on thread.meta.update. Absent on + older servers, so clients hide the action instead of sending it. */ + threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1ebc23a483b..7dc54a99aea 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -320,12 +320,15 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ threadId: "thread-1", + regenerateTitle: true, + previousTitle: "Previous title", modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", }, updatedAt: "2026-01-01T00:00:00.000Z", }); + assert.strictEqual(parsed.previousTitle, "Previous title"); assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent"); }), ); @@ -628,6 +631,36 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("accepts a title regeneration intent in thread.meta.update", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate", + threadId: "thread-1", + regenerateTitle: true, + }); + assert.strictEqual(parsed.type, "thread.meta.update"); + if (parsed.type === "thread.meta.update") { + assert.strictEqual(parsed.regenerateTitle, true); + } + }), +); + +it.effect("rejects an explicit title combined with title regeneration", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate-with-title", + threadId: "thread-1", + title: "Explicit title", + regenerateTitle: true, + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4c..03879301486 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -616,11 +616,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), -}); +}).check( + Schema.makeFilter( + (input) => + !(input.title !== undefined && input.regenerateTitle === true) || + "title and regenerateTitle cannot be specified together", + ), +); const ThreadRuntimeModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), @@ -999,6 +1006,11 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + /** Intent marker consumed by the title-generation reactor. Keeping this on + the existing event lets older clients safely ignore the new field. */ + regenerateTitle: Schema.optional(Schema.Literal(true)), + /** Title at request time, used to avoid overwriting a later manual rename. */ + previousTitle: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From 4ca8faba03f5be049c75631b80b83aadea2871dc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 21:42:47 -0700 Subject: [PATCH 02/13] feat(web): show title regeneration progress --- .../Layers/ProjectionPipeline.ts | 8 +++ .../Layers/ProjectionSnapshotQuery.ts | 23 ++++++++ .../Layers/ProviderCommandReactor.test.ts | 53 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 52 +++++++++++------- apps/server/src/orchestration/decider.ts | 32 +++++++++++ apps/server/src/orchestration/projector.ts | 3 ++ .../persistence/Layers/ProjectionThreads.ts | 10 ++++ apps/server/src/persistence/Migrations.ts | 2 + ..._ProjectionThreadTitleRegeneration.test.ts | 27 ++++++++++ .../035_ProjectionThreadTitleRegeneration.ts | 23 ++++++++ .../persistence/Services/ProjectionThreads.ts | 3 ++ apps/web/src/components/Sidebar.logic.test.ts | 36 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 24 +++++++++ apps/web/src/components/SidebarV2.tsx | 48 +++++++++++++++-- .../client-runtime/src/state/threadReducer.ts | 3 ++ packages/contracts/src/orchestration.test.ts | 22 ++++++++ packages/contracts/src/orchestration.ts | 20 +++++++ 17 files changed, 366 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..f6c62b610ab 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -611,6 +611,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegenerationRequestId: null, + titleRegenerationStartedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -723,6 +725,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleRegeneration !== undefined + ? { + titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, + titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null, + } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..1cd4289fe0c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -209,6 +209,15 @@ function mapLatestTurn( }; } +function mapTitleRegeneration(row: Schema.Schema.Type) { + return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null + ? { + requestId: row.titleRegenerationRequestId, + startedAt: row.titleRegenerationStartedAt, + } + : null; +} + function mapSessionRow( row: Schema.Schema.Type, ): OrchestrationSession { @@ -338,6 +347,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -370,6 +381,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -404,6 +417,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -770,6 +785,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1206,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1408,6 +1426,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1541,6 +1560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1679,6 +1699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1923,6 +1944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2021,6 +2043,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 945bd32df98..3423eb84d3c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -715,6 +715,7 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Resolve stale reconnect state"); + expect(thread?.titleRegeneration).toBeNull(); }); it("keeps the current title when regeneration returns the fallback", async () => { @@ -760,6 +761,52 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Keep meaningful title"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("clears title regeneration state when generation fails", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep title after failure", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-failed-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep title after failure"); + expect(thread?.titleRegeneration).toBeNull(); }); it("keeps the full retained context and excludes attachments outside it", async () => { @@ -876,6 +923,11 @@ describe("ProviderCommandReactor", () => { }), ); await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + const pendingReadModel = await harness.readModel(); + expect( + pendingReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.titleRegeneration?.requestId, + ).toBe(CommandId.make("cmd-thread-title-regeneration-race")); await harness.runEffect( harness.engine.dispatch({ @@ -893,6 +945,7 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Keep manual rename"); + expect(thread?.titleRegeneration).toBeNull(); }); it("does not overwrite a manual rename while title regeneration is queued", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cbde7ccf29c..23f4a336cae 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -877,27 +877,39 @@ const make = Effect.gen(function* () { return; } - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("thread-title-regenerate"), - threadId: event.payload.threadId, - title: generated.title, - }); + return generated.title; }); - const processThreadTitleRegenerationSafely = ( - event: Extract, - ) => - regenerateThreadTitle(event).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("provider command reactor failed to regenerate thread title", { - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }); - }), - ); + const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")( + function* (event: Extract) { + if (event.payload.regenerateTitle !== true) { + return; + } + + const requestId = event.payload.titleRegeneration?.requestId ?? event.commandId; + const generatedTitle = yield* regenerateThreadTitle(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)); + }), + ); + if (requestId === null) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.title.regeneration.complete", + commandId: yield* serverCommandId("thread-title-regeneration-complete"), + threadId: event.payload.threadId, + requestId, + ...(generatedTitle !== undefined ? { title: generatedTitle } : {}), + }); + }, + ); const threadTitleRegenerationWorker = yield* makeDrainableWorker( processThreadTitleRegenerationSafely, ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e5e3f84156e..982e4495ae1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -658,8 +658,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { regenerateTitle: true as const, previousTitle: thread.title, + titleRegeneration: { + requestId: command.commandId, + startedAt: occurredAt, + }, } : {}), + ...(command.title !== undefined && thread.titleRegeneration != null + ? { titleRegeneration: null } + : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), @@ -670,6 +677,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.title.regeneration.complete": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), + ...(requestIsCurrent ? { titleRegeneration: null } : {}), + updatedAt: occurredAt, + }, + }; + } + case "thread.runtime-mode.set": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..54e694dd332 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -399,6 +399,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleRegeneration !== undefined + ? { titleRegeneration: payload.titleRegeneration } + : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7e86d49eac3..eb423aef99e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,6 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at, snoozed_until, snoozed_at, + title_regeneration_request_id, + title_regeneration_started_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -70,6 +72,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.settledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, + ${row.titleRegenerationRequestId ?? null}, + ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -93,6 +97,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at = excluded.settled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, + title_regeneration_request_id = excluded.title_regeneration_request_id, + title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -123,6 +129,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -155,6 +163,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a9..95cb6b17f84 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionThreadTitleRegeneration", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts new file mode 100644 index 00000000000..755591201de --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("035_ProjectionThreadTitleRegeneration", (it) => { + it.effect("adds pending title regeneration columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 34 }); + yield* runMigrations({ toMigrationInclusive: 35 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const names = new Set(columns.map((column) => column.name)); + assert.ok(names.has("title_regeneration_request_id")); + assert.ok(names.has("title_regeneration_started_at")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts new file mode 100644 index 00000000000..ee3dd4a0774 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "title_regeneration_request_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_request_id TEXT + `; + } + + if (!columns.some((column) => column.name === "title_regeneration_started_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_started_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..ea1b011be84 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadRepository */ import { + CommandId, IsoDateTime, ModelSelection, NonNegativeInt, @@ -40,6 +41,8 @@ export const ProjectionThread = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), + titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), + titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..2d6b4f78481 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -10,6 +10,7 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isThreadTitleRegenerationPending, isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -28,6 +29,7 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + THREAD_TITLE_REGENERATION_TIMEOUT_MS, } from "./Sidebar.logic"; import { EnvironmentId, @@ -46,6 +48,40 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("isThreadTitleRegenerationPending", () => { + const now = Date.parse("2026-03-09T10:00:30.000Z"); + + it("keeps a recent request pending", () => { + expect( + isThreadTitleRegenerationPending( + { + titleRegeneration: { + startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS + 1).toISOString(), + }, + }, + now, + ), + ).toBe(true); + }); + + it("expires stale, missing, and malformed requests", () => { + expect( + isThreadTitleRegenerationPending( + { + titleRegeneration: { + startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS).toISOString(), + }, + }, + now, + ), + ).toBe(false); + expect(isThreadTitleRegenerationPending({ titleRegeneration: null }, now)).toBe(false); + expect( + isThreadTitleRegenerationPending({ titleRegeneration: { startedAt: "not-a-date" } }, now), + ).toBe(false); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..3866405efd5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,9 +15,33 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_TITLE_REGENERATION_TIMEOUT_MS = 30_000; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; + +export function threadTitleRegenerationRemainingMs( + thread: { + readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; + }, + nowMs: number, +): number { + const startedAt = Date.parse(thread.titleRegeneration?.startedAt ?? ""); + if (!Number.isFinite(startedAt)) return 0; + return Math.min( + THREAD_TITLE_REGENERATION_TIMEOUT_MS, + Math.max(0, THREAD_TITLE_REGENERATION_TIMEOUT_MS - (nowMs - startedAt)), + ); +} + +export function isThreadTitleRegenerationPending( + thread: { + readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; + }, + nowMs: number, +): boolean { + return threadTitleRegenerationRemainingMs(thread, nowMs) > 0; +} type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 258240891cb..7abdef8ff88 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -109,6 +109,7 @@ import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + isThreadTitleRegenerationPending, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, @@ -119,6 +120,7 @@ import { sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + threadTitleRegenerationRemainingMs, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -444,6 +446,23 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const titleRegeneration = thread.titleRegeneration ?? null; + const [timedOutTitleRegenerationRequestId, setTimedOutTitleRegenerationRequestId] = useState< + string | null + >(null); + const titleRegenerationRemaining = threadTitleRegenerationRemainingMs(thread, Date.now()); + const isRegeneratingTitle = + titleRegeneration !== null && + timedOutTitleRegenerationRequestId !== titleRegeneration.requestId && + titleRegenerationRemaining > 0; + useEffect(() => { + if (titleRegeneration === null || titleRegenerationRemaining <= 0) return; + const timeoutId = window.setTimeout( + () => setTimedOutTitleRegenerationRequestId(titleRegeneration.requestId), + titleRegenerationRemaining, + ); + return () => window.clearTimeout(timeoutId); + }, [titleRegeneration, titleRegenerationRemaining]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); @@ -721,7 +740,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( {thread.title} @@ -790,6 +810,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { role="button" tabIndex={0} data-testid="sidebar-v2-row-slim" + aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -816,6 +837,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {title} {terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} @@ -900,6 +926,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { role="button" tabIndex={0} data-testid="sidebar-v2-row-card" + aria-busy={isRegeneratingTitle || undefined} className={rowSurfaceClassName} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -998,7 +1025,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} -
{title}
+
+ {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
{thread.branch ? ( {thread.branch} @@ -2066,6 +2100,7 @@ export default function SidebarV2() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; + const isRegeneratingTitle = isThreadTitleRegenerationPending(thread, Date.now()); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2105,7 +2140,13 @@ export default function SidebarV2() { : []), { id: "rename", label: "Rename thread" }, ...(supportsTitleRegeneration - ? [{ id: "regenerate-title", label: "Regenerate title" }] + ? [ + { + id: "regenerate-title", + label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + disabled: isRegeneratingTitle, + }, + ] : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy path", icon: "copy" }, @@ -2160,6 +2201,7 @@ export default function SidebarV2() { startThreadRename(threadRef, thread.title); return; case "regenerate-title": { + if (isRegeneratingTitle) return; const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, regenerateTitle: true }, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ce0dca52f5a..fe637770804 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -155,6 +155,9 @@ export function applyThreadDetailEvent( thread: { ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleRegeneration !== undefined + ? { titleRegeneration: event.payload.titleRegeneration } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 7dc54a99aea..ecf7afa0610 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -322,6 +322,10 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => threadId: "thread-1", regenerateTitle: true, previousTitle: "Previous title", + titleRegeneration: { + requestId: "cmd-title-regenerate", + startedAt: "2026-01-01T00:00:00.000Z", + }, modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -329,6 +333,7 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.previousTitle, "Previous title"); + assert.strictEqual(parsed.titleRegeneration?.requestId, "cmd-title-regenerate"); assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent"); }), ); @@ -646,6 +651,23 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () => }), ); +it.effect("accepts an internal title regeneration completion", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.title.regeneration.complete", + commandId: "cmd-title-regeneration-complete", + threadId: "thread-1", + requestId: "cmd-title-regenerate", + title: "Updated title", + }); + assert.strictEqual(parsed.type, "thread.title.regeneration.complete"); + if (parsed.type === "thread.title.regeneration.complete") { + assert.strictEqual(parsed.requestId, "cmd-title-regenerate"); + assert.strictEqual(parsed.title, "Updated title"); + } + }), +); + it.effect("rejects an explicit title combined with title regeneration", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 03879301486..81d647ece7d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -341,6 +341,12 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadTitleRegeneration = Schema.Struct({ + requestId: CommandId, + startedAt: IsoDateTime, +}); +export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -366,6 +372,8 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Pending-only state. Optional so older servers remain compatible. + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -418,6 +426,7 @@ export const OrchestrationThreadShell = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -866,6 +875,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.title.regeneration.complete"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + title: Schema.optional(TrimmedNonEmptyString), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -874,6 +891,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1011,6 +1029,8 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ regenerateTitle: Schema.optional(Schema.Literal(true)), /** Title at request time, used to avoid overwriting a later manual rename. */ previousTitle: Schema.optional(TrimmedNonEmptyString), + /** Pending state shared with clients. Null clears a matching request. */ + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From df6ad37d1157ab23ce7113a79b482333add8b700 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 21:47:22 -0700 Subject: [PATCH 03/13] test(server): include title regeneration in snapshots --- .../src/orchestration/Layers/ProjectionSnapshotQuery.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..12afffea7e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegeneration: null, deletedAt: null, messages: [ { @@ -426,6 +427,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), status: "running", From 1a74e2c8663e16f9fb50e984b4841c0efdfe68bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 21:53:53 -0700 Subject: [PATCH 04/13] fix(server): prioritize thread intent in titles --- .../src/textGeneration/TextGenerationPrompts.test.ts | 3 +++ apps/server/src/textGeneration/TextGenerationPrompts.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 209bbf3cc79..ede18664051 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -187,6 +187,9 @@ describe("buildThreadTitlePrompt", () => { ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); expect(result.prompt).toContain("better represents the current state of the thread"); + expect(result.prompt).toContain( + "Capture the thread's intent, not a PR number or other superficial detail.", + ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index b7968c4797c..8aed88b1f16 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -245,7 +245,12 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { isRegeneration ? "Title should summarize the thread's current state, not just its initial request." : "Title should summarize the user's request, not restate it verbatim.", - ...(isRegeneration ? ["Return a different title from the previous title."] : []), + ...(isRegeneration + ? [ + "Capture the thread's intent, not a PR number or other superficial detail.", + "Return a different title from the previous title.", + ] + : []), "Keep it short and specific (3-8 words).", "Avoid quotes, filler, prefixes, and trailing punctuation.", "If images are attached, use them as primary context for visual/UI issues.", From 31544cff281b56449e0bc361971e2939c7b46ee8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 22:03:49 -0700 Subject: [PATCH 05/13] fix(server): keep title regeneration worker alive --- .../Layers/ProviderCommandReactor.test.ts | 93 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 15 +++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3423eb84d3c..15c96cfb8f1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -146,6 +146,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly titleRegenerationCompletionDispatchFailures?: number; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -353,8 +354,34 @@ describe("ProviderCommandReactor", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + let titleRegenerationCompletionDispatchAttempts = 0; + const reactorOrchestrationLayer = Layer.effect( + OrchestrationEngineService, + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + return { + readEvents: engine.readEvents, + dispatch: (command) => { + if (command.type === "thread.title.regeneration.complete") { + titleRegenerationCompletionDispatchAttempts += 1; + if ( + titleRegenerationCompletionDispatchAttempts <= + (input?.titleRegenerationCompletionDispatchFailures ?? 0) + ) { + return Effect.die(new Error("Injected title regeneration completion failure")); + } + } + return engine.dispatch(command); + }, + get streamDomainEvents() { + return engine.streamDomainEvents; + }, + latestSequence: engine.latestSequence, + } satisfies OrchestrationEngineService["Service"]; + }), + ).pipe(Layer.provide(orchestrationLayer)); const layer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), @@ -436,6 +463,9 @@ describe("ProviderCommandReactor", () => { stateDir, drain, runEffect, + get titleRegenerationCompletionDispatchAttempts() { + return titleRegenerationCompletionDispatchAttempts; + }, }; } @@ -809,6 +839,67 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); + it("continues regenerating after a completion dispatch fails", async () => { + const harness = await createHarness({ + titleRegenerationCompletionDispatchFailures: 1, + }); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle + .mockReturnValueOnce(Effect.succeed({ title: "Title lost to completion failure" })) + .mockReturnValueOnce(Effect.succeed({ title: "Recovered regeneration worker" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-completion-failure"), + threadId: ThreadId.make("thread-1"), + title: "Existing title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-completion-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-completion-failure"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regeneration-completion-failure"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regeneration-after-completion-failure"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(2); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(2); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Recovered regeneration worker"); + expect(thread?.titleRegeneration).toBeNull(); + }); + it("keeps the full retained context and excludes attachments outside it", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 23f4a336cae..764904a0cd5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -909,6 +909,21 @@ const make = Effect.gen(function* () { ...(generatedTitle !== undefined ? { title: generatedTitle } : {}), }); }, + (effect, event) => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning( + "provider command reactor failed to complete title regeneration", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ); + }), + ), ); const threadTitleRegenerationWorker = yield* makeDrainableWorker( processThreadTitleRegenerationSafely, From 1790449fcf15abf58bf26b0a889924abcd7874a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 22:11:51 -0700 Subject: [PATCH 06/13] fix(server): retry title regeneration completion --- .../Layers/ProviderCommandReactor.test.ts | 13 ++++--- .../Layers/ProviderCommandReactor.ts | 35 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 15c96cfb8f1..2aeb09c2d49 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -839,7 +839,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); - it("continues regenerating after a completion dispatch fails", async () => { + it("retries a failed completion and continues regenerating", async () => { const harness = await createHarness({ titleRegenerationCompletionDispatchFailures: 1, }); @@ -882,6 +882,11 @@ describe("ProviderCommandReactor", () => { ); await harness.drain(); + let readModel = await harness.readModel(); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Title lost to completion failure"); + expect(thread?.titleRegeneration).toBeNull(); + await harness.runEffect( harness.engine.dispatch({ type: "thread.meta.update", @@ -893,9 +898,9 @@ describe("ProviderCommandReactor", () => { await harness.drain(); expect(harness.generateThreadTitle).toHaveBeenCalledTimes(2); - expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(2); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(3); + readModel = await harness.readModel(); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Recovered regeneration worker"); expect(thread?.titleRegeneration).toBeNull(); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 764904a0cd5..1f84482dac3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -879,6 +879,21 @@ const make = Effect.gen(function* () { return generated.title; }); + const dispatchThreadTitleRegenerationCompletion = Effect.fn( + "dispatchThreadTitleRegenerationCompletion", + )(function* (input: { + readonly threadId: ThreadId; + readonly requestId: CommandId; + readonly title?: string; + }) { + yield* orchestrationEngine.dispatch({ + type: "thread.title.regeneration.complete", + commandId: yield* serverCommandId("thread-title-regeneration-complete"), + threadId: input.threadId, + requestId: input.requestId, + ...(input.title !== undefined ? { title: input.title } : {}), + }); + }); const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")( function* (event: Extract) { if (event.payload.regenerateTitle !== true) { @@ -901,13 +916,25 @@ const make = Effect.gen(function* () { return; } - yield* orchestrationEngine.dispatch({ - type: "thread.title.regeneration.complete", - commandId: yield* serverCommandId("thread-title-regeneration-complete"), + const completion = { threadId: event.payload.threadId, requestId, ...(generatedTitle !== undefined ? { title: generatedTitle } : {}), - }); + }; + yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning( + "provider command reactor retrying title regeneration completion", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))); + }), + ); }, (effect, event) => effect.pipe( From cb2cd07d94c5545dbe935cfc2934cae31523f489 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 23:52:32 -0700 Subject: [PATCH 07/13] feat(web): regenerate selected thread titles (#4814) --- apps/web/src/components/Sidebar.logic.test.ts | 37 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 19 ++++++++ apps/web/src/components/SidebarV2.tsx | 47 +++++++++++++++++-- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2d6b4f78481..fa4eb5047af 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -82,6 +83,42 @@ describe("isThreadTitleRegenerationPending", () => { }); }); +describe("buildBulkTitleRegenerationContextMenuItem", () => { + it("counts only threads that can start a new regeneration", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 4, + actionableCount: 3, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerate titles (3)", + }); + }); + + it("shows a disabled progress item when every supported thread is pending", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 2, + actionableCount: 0, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerating… (2)", + disabled: true, + }); + }); + + it("omits the action when no selected environment supports it", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 0, + actionableCount: 0, + }), + ).toBeNull(); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3866405efd5..40f974a4b5e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -42,6 +42,25 @@ export function isThreadTitleRegenerationPending( ): boolean { return threadTitleRegenerationRemainingMs(thread, nowMs) > 0; } + +export function buildBulkTitleRegenerationContextMenuItem(input: { + supportedCount: number; + actionableCount: number; +}): ContextMenuItem<"regenerate-title"> | null { + if (input.supportedCount === 0) return null; + if (input.actionableCount === 0) { + return { + id: "regenerate-title", + label: `Regenerating… (${input.supportedCount})`, + disabled: true, + }; + } + return { + id: "regenerate-title", + label: `Regenerate titles (${input.actionableCount})`, + }; +} + type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 7abdef8ff88..536589bd2bf 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -106,6 +106,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -1946,16 +1947,28 @@ export default function SidebarV2() { const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + const selectionNow = new Date(); + const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); - const canSnoozeSelection = snoozableThreads.every( + const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow }), + canSnooze(thread, { now: selectionNow.toISOString() }), ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, + ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => !isThreadTitleRegenerationPending(thread, selectionNow.getTime()), + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1973,6 +1986,7 @@ export default function SidebarV2() { }, ] : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1988,7 +2002,7 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { + for (const thread of selectedThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, }); @@ -1997,6 +2011,28 @@ export default function SidebarV2() { } return; } + if (clicked.value === "regenerate-title") { + for (const thread of regeneratableTitleThreads) { + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Success") continue; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread titles", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + clearSelection(); + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -2068,6 +2104,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + updateThreadMetadata, ], ); From 9d547e3044b785149a437752c2b807e7da86a283 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 02:21:29 -0700 Subject: [PATCH 08/13] fix(server): preserve recency after stale title generation --- .../decider.titleRegeneration.test.ts | 72 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/orchestration/decider.titleRegeneration.test.ts diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts new file mode 100644 index 00000000000..b29c8ffda67 --- /dev/null +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -0,0 +1,72 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const UPDATED_AT = "2026-01-01T00:00:00.000Z"; + +const readModel: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Manual title", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: UPDATED_AT, + updatedAt: UPDATED_AT, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: UPDATED_AT, +}; + +it.layer(NodeServices.layer)("title regeneration decider", (it) => { + it.effect("preserves updatedAt for a stale completion", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.title.regeneration.complete", + commandId: CommandId.make("cmd-regeneration-complete"), + threadId: ThreadId.make("thread-1"), + requestId: CommandId.make("cmd-old-regeneration-request"), + title: "Generated title", + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload).toEqual({ + threadId: ThreadId.make("thread-1"), + updatedAt: UPDATED_AT, + }); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 982e4495ae1..ae9a068864c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -697,7 +697,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), ...(requestIsCurrent ? { titleRegeneration: null } : {}), - updatedAt: occurredAt, + updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt, }, }; } From ba79848a8b5960f272dddffa60023268aadc775e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 03:09:13 -0700 Subject: [PATCH 09/13] fix: keep title regeneration state server-backed --- .../Layers/ProviderCommandReactor.test.ts | 91 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 70 +++++++++++--- apps/web/src/components/Sidebar.logic.test.ts | 73 --------------- apps/web/src/components/Sidebar.logic.ts | 42 --------- apps/web/src/components/SidebarV2.tsx | 59 +----------- 5 files changed, 146 insertions(+), 189 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2aeb09c2d49..cea71b17c71 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -147,6 +147,7 @@ describe("ProviderCommandReactor", () => { readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; + readonly titleRegenerationBeforeStart?: boolean; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -415,9 +416,6 @@ describe("ProviderCommandReactor", () => { const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); - const drain = () => Effect.runPromise(reactor.drain); await Effect.runPromise( engine.dispatch({ @@ -445,6 +443,20 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); + if (input?.titleRegenerationBeforeStart === true) { + await Effect.runPromise( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regeneration-before-reactor-start"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + } + + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + const drain = () => Effect.runPromise(reactor.drain); return { engine, @@ -748,6 +760,19 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); + it("clears title regeneration state left pending across reactor startup", async () => { + const harness = await createHarness({ + titleRegenerationBeforeStart: true, + }); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Thread"); + expect(thread?.titleRegeneration).toBeNull(); + }); + it("keeps the current title when regeneration returns the fallback", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1108,6 +1133,64 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Keep queued manual rename"); }); + it("skips superseded title regeneration before generation starts", async () => { + let releaseStart = () => {}; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const harness = await createHarness({ + startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)), + }); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Latest regenerated title" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-superseded-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-superseded-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-superseded-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-latest-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + releaseStart(); + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Latest regenerated title"); + expect(thread?.titleRegeneration).toBeNull(); + }); + it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2668,7 +2751,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1f84482dac3..d28f0293745 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -834,24 +834,25 @@ const make = Effect.gen(function* () { const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* ( event: Extract, + requestId: CommandId, ) { if (event.payload.regenerateTitle !== true) { - return; + return { _tag: "Superseded" } as const; } const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { - return; + if (!thread || thread.titleRegeneration?.requestId !== requestId) { + return { _tag: "Superseded" } as const; } const { message, attachments } = formatThreadTitleContext(thread.messages); if (message.length === 0) { - return; + return { _tag: "Completed", title: undefined } as const; } const previousTitle = event.payload.previousTitle ?? thread.title; if (thread.title !== previousTitle) { - return; + return { _tag: "Superseded" } as const; } const project = yield* resolveProject(thread.projectId); const cwd = @@ -869,15 +870,19 @@ const make = Effect.gen(function* () { modelSelection, }); if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) { - return; + return { _tag: "Completed", title: undefined } as const; } const latestThread = yield* resolveThread(event.payload.threadId); - if (!latestThread || latestThread.title !== previousTitle) { - return; + if ( + !latestThread || + latestThread.titleRegeneration?.requestId !== requestId || + latestThread.title !== previousTitle + ) { + return { _tag: "Superseded" } as const; } - return generated.title; + return { _tag: "Completed", title: generated.title } as const; }); const dispatchThreadTitleRegenerationCompletion = Effect.fn( "dispatchThreadTitleRegenerationCompletion", @@ -894,6 +899,25 @@ const make = Effect.gen(function* () { ...(input.title !== undefined ? { title: input.title } : {}), }); }); + const clearInterruptedThreadTitleRegenerations = Effect.fn( + "clearInterruptedThreadTitleRegenerations", + )(function* () { + const readModel = yield* projectionSnapshotQuery.getCommandReadModel(); + yield* Effect.forEach( + readModel.threads, + (thread) => { + const requestId = thread.titleRegeneration?.requestId; + if (requestId === undefined) { + return Effect.void; + } + return dispatchThreadTitleRegenerationCompletion({ + threadId: thread.id, + requestId, + }); + }, + { discard: true }, + ); + }); const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")( function* (event: Extract) { if (event.payload.regenerateTitle !== true) { @@ -901,7 +925,10 @@ const make = Effect.gen(function* () { } const requestId = event.payload.titleRegeneration?.requestId ?? event.commandId; - const generatedTitle = yield* regenerateThreadTitle(event).pipe( + if (requestId === null) { + return; + } + const result = yield* regenerateThreadTitle(event, requestId).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); @@ -909,17 +936,17 @@ const make = Effect.gen(function* () { return Effect.logWarning("provider command reactor failed to regenerate thread title", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }).pipe(Effect.as(undefined)); + }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); }), ); - if (requestId === null) { + if (result._tag === "Superseded") { return; } const completion = { threadId: event.payload.threadId, requestId, - ...(generatedTitle !== undefined ? { title: generatedTitle } : {}), + ...(result.title !== undefined ? { title: result.title } : {}), }; yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( Effect.catchCause((cause) => { @@ -1276,6 +1303,23 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { + // The domain event stream is hot, so work pending before this reactor + // starts cannot be resumed. Correlated completions only clear the request + // captured here, leaving any newer request untouched. + yield* clearInterruptedThreadTitleRegenerations().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to clear interrupted title regenerations", + { + cause: Cause.pretty(cause), + }, + ); + }), + ); + const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fa4eb5047af..59784bf8fac 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, - buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -11,7 +10,6 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, - isThreadTitleRegenerationPending, isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -30,7 +28,6 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, - THREAD_TITLE_REGENERATION_TIMEOUT_MS, } from "./Sidebar.logic"; import { EnvironmentId, @@ -49,76 +46,6 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("isThreadTitleRegenerationPending", () => { - const now = Date.parse("2026-03-09T10:00:30.000Z"); - - it("keeps a recent request pending", () => { - expect( - isThreadTitleRegenerationPending( - { - titleRegeneration: { - startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS + 1).toISOString(), - }, - }, - now, - ), - ).toBe(true); - }); - - it("expires stale, missing, and malformed requests", () => { - expect( - isThreadTitleRegenerationPending( - { - titleRegeneration: { - startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS).toISOString(), - }, - }, - now, - ), - ).toBe(false); - expect(isThreadTitleRegenerationPending({ titleRegeneration: null }, now)).toBe(false); - expect( - isThreadTitleRegenerationPending({ titleRegeneration: { startedAt: "not-a-date" } }, now), - ).toBe(false); - }); -}); - -describe("buildBulkTitleRegenerationContextMenuItem", () => { - it("counts only threads that can start a new regeneration", () => { - expect( - buildBulkTitleRegenerationContextMenuItem({ - supportedCount: 4, - actionableCount: 3, - }), - ).toEqual({ - id: "regenerate-title", - label: "Regenerate titles (3)", - }); - }); - - it("shows a disabled progress item when every supported thread is pending", () => { - expect( - buildBulkTitleRegenerationContextMenuItem({ - supportedCount: 2, - actionableCount: 0, - }), - ).toEqual({ - id: "regenerate-title", - label: "Regenerating… (2)", - disabled: true, - }); - }); - - it("omits the action when no selected environment supports it", () => { - expect( - buildBulkTitleRegenerationContextMenuItem({ - supportedCount: 0, - actionableCount: 0, - }), - ).toBeNull(); - }); -}); - describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 40f974a4b5e..c94acfbc579 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,52 +15,10 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; -export const THREAD_TITLE_REGENERATION_TIMEOUT_MS = 30_000; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; -export function threadTitleRegenerationRemainingMs( - thread: { - readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; - }, - nowMs: number, -): number { - const startedAt = Date.parse(thread.titleRegeneration?.startedAt ?? ""); - if (!Number.isFinite(startedAt)) return 0; - return Math.min( - THREAD_TITLE_REGENERATION_TIMEOUT_MS, - Math.max(0, THREAD_TITLE_REGENERATION_TIMEOUT_MS - (nowMs - startedAt)), - ); -} - -export function isThreadTitleRegenerationPending( - thread: { - readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; - }, - nowMs: number, -): boolean { - return threadTitleRegenerationRemainingMs(thread, nowMs) > 0; -} - -export function buildBulkTitleRegenerationContextMenuItem(input: { - supportedCount: number; - actionableCount: number; -}): ContextMenuItem<"regenerate-title"> | null { - if (input.supportedCount === 0) return null; - if (input.actionableCount === 0) { - return { - id: "regenerate-title", - label: `Regenerating… (${input.supportedCount})`, - disabled: true, - }; - } - return { - id: "regenerate-title", - label: `Regenerate titles (${input.actionableCount})`, - }; -} - type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 536589bd2bf..e1bbbcd4987 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -106,11 +106,9 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { - buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, - isThreadTitleRegenerationPending, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, @@ -121,7 +119,6 @@ import { sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, - threadTitleRegenerationRemainingMs, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -447,23 +444,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); - const titleRegeneration = thread.titleRegeneration ?? null; - const [timedOutTitleRegenerationRequestId, setTimedOutTitleRegenerationRequestId] = useState< - string | null - >(null); - const titleRegenerationRemaining = threadTitleRegenerationRemainingMs(thread, Date.now()); - const isRegeneratingTitle = - titleRegeneration !== null && - timedOutTitleRegenerationRequestId !== titleRegeneration.requestId && - titleRegenerationRemaining > 0; - useEffect(() => { - if (titleRegeneration === null || titleRegenerationRemaining <= 0) return; - const timeoutId = window.setTimeout( - () => setTimedOutTitleRegenerationRequestId(titleRegeneration.requestId), - titleRegenerationRemaining, - ); - return () => window.clearTimeout(timeoutId); - }, [titleRegeneration, titleRegenerationRemaining]); + const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); @@ -1957,18 +1938,6 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); - const titleRegenerationThreads = selectedThreads.filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true, - ); - const regeneratableTitleThreads = titleRegenerationThreads.filter( - (thread) => !isThreadTitleRegenerationPending(thread, selectionNow.getTime()), - ); - const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ - supportedCount: titleRegenerationThreads.length, - actionableCount: regeneratableTitleThreads.length, - }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1986,7 +1955,6 @@ export default function SidebarV2() { }, ] : []), - ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -2011,28 +1979,6 @@ export default function SidebarV2() { } return; } - if (clicked.value === "regenerate-title") { - for (const thread of regeneratableTitleThreads) { - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, - }); - if (result._tag === "Success") continue; - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread titles", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - clearSelection(); - return; - } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -2104,7 +2050,6 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, - updateThreadMetadata, ], ); @@ -2137,7 +2082,7 @@ export default function SidebarV2() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; - const isRegeneratingTitle = isThreadTitleRegenerationPending(thread, Date.now()); + const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). From 1f3a9d807a56db48ff11a06d2790dc25d8526efc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 03:22:52 -0700 Subject: [PATCH 10/13] fix(server): recover stale title regeneration state --- .../Layers/OrchestrationEngine.test.ts | 28 ++++++++++ .../Layers/ProjectionPipeline.ts | 2 + .../Layers/ProviderCommandReactor.test.ts | 54 ++++++++++++++++--- .../Layers/ProviderCommandReactor.ts | 49 ++++++++++------- apps/server/src/orchestration/projector.ts | 1 + 5 files changed, 110 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..3c039d2260f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -335,6 +335,14 @@ describe("OrchestrationEngine", () => { }), ); + await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-archive-title-regeneration"), + threadId: ThreadId.make("thread-archive"), + regenerateTitle: true, + }), + ); await system.run( engine.dispatch({ type: "thread.archive", @@ -346,6 +354,10 @@ describe("OrchestrationEngine", () => { (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") ?.archivedAt, ).not.toBeNull(); + expect( + (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") + ?.titleRegeneration, + ).toBeNull(); await system.run( engine.dispatch({ @@ -358,6 +370,22 @@ describe("OrchestrationEngine", () => { (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") ?.archivedAt, ).toBeNull(); + expect( + (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") + ?.titleRegeneration, + ).toBeNull(); + await system.run( + engine.dispatch({ + type: "thread.title.regeneration.complete", + commandId: CommandId.make("cmd-thread-archive-stale-title-completion"), + threadId: ThreadId.make("thread-archive"), + requestId: CommandId.make("cmd-thread-archive-title-regeneration"), + title: "Stale generated title", + }), + ); + expect( + (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")?.title, + ).toBe("Archive me"); await system.dispose(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f6c62b610ab..1a08f7aeeae 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -631,6 +631,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, archivedAt: event.payload.archivedAt, + titleRegenerationRequestId: null, + titleRegenerationStartedAt: null, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index cea71b17c71..c4f15bee6d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -147,7 +147,7 @@ describe("ProviderCommandReactor", () => { readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; - readonly titleRegenerationBeforeStart?: boolean; + readonly titleRegenerationBeforeStart?: "one" | "two"; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -443,12 +443,37 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - if (input?.titleRegenerationBeforeStart === true) { + if (input?.titleRegenerationBeforeStart === "two") { + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-2"), + threadId: ThreadId.make("thread-2"), + projectId: asProjectId("project-1"), + title: "Thread 2", + modelSelection: modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + } + const titleRegenerationThreadIds = + input?.titleRegenerationBeforeStart === "two" + ? [ThreadId.make("thread-1"), ThreadId.make("thread-2")] + : input?.titleRegenerationBeforeStart === "one" + ? [ThreadId.make("thread-1")] + : []; + for (const [index, threadId] of titleRegenerationThreadIds.entries()) { await Effect.runPromise( engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-regeneration-before-reactor-start"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.make( + `cmd-thread-title-regeneration-before-reactor-start-${index + 1}`, + ), + threadId, regenerateTitle: true, }), ); @@ -762,7 +787,7 @@ describe("ProviderCommandReactor", () => { it("clears title regeneration state left pending across reactor startup", async () => { const harness = await createHarness({ - titleRegenerationBeforeStart: true, + titleRegenerationBeforeStart: "one", }); expect(harness.generateThreadTitle).not.toHaveBeenCalled(); @@ -773,6 +798,23 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); + it("continues clearing startup title regeneration state after one completion fails", async () => { + const harness = await createHarness({ + titleRegenerationBeforeStart: "two", + titleRegenerationCompletionDispatchFailures: 1, + }); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(2); + const readModel = await harness.readModel(); + expect( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.titleRegeneration, + ).not.toBeNull(); + expect( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-2"))?.titleRegeneration, + ).toBeNull(); + }); + it("keeps the current title when regeneration returns the fallback", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2751,7 +2793,7 @@ describe("ProviderCommandReactor", () => { }), ); - await harness.runEffect( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d28f0293745..6ddc9f18cb3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -913,7 +913,20 @@ const make = Effect.gen(function* () { return dispatchThreadTitleRegenerationCompletion({ threadId: thread.id, requestId, - }); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to clear interrupted title regeneration", + { + threadId: thread.id, + cause: Cause.pretty(cause), + }, + ); + }), + ); }, { discard: true }, ); @@ -1303,23 +1316,6 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { - // The domain event stream is hot, so work pending before this reactor - // starts cannot be resumed. Correlated completions only clear the request - // captured here, leaving any newer request untouched. - yield* clearInterruptedThreadTitleRegenerations().pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.interrupt; - } - return Effect.logWarning( - "provider command reactor failed to clear interrupted title regenerations", - { - cause: Cause.pretty(cause), - }, - ); - }), - ); - const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || @@ -1337,6 +1333,23 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), ); + + // The domain event stream is hot, so work pending before this reactor + // starts cannot be resumed. Correlated completions only clear the request + // captured here, leaving any newer request untouched. + yield* clearInterruptedThreadTitleRegenerations().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to clear interrupted title regenerations", + { + cause: Cause.pretty(cause), + }, + ); + }), + ); }); return { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 54e694dd332..4be5843c1ae 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -329,6 +329,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { archivedAt: payload.archivedAt, + titleRegeneration: null, updatedAt: payload.updatedAt, }), })), From a5dfcbd622ac6aac5b7fe1127674739c7575311f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 03:25:35 -0700 Subject: [PATCH 11/13] fix(test): use reactor harness runtime --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c4f15bee6d7..e4661061b23 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2774,7 +2774,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-stop"), @@ -2793,7 +2793,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), From a4d16a9f50296555d51c0bff475810b507496ff5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 04:04:03 -0700 Subject: [PATCH 12/13] feat(web): restore bulk title regeneration --- apps/web/src/components/Sidebar.logic.test.ts | 37 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 18 +++++++++ apps/web/src/components/SidebarV2.tsx | 37 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..9c1e7c8c563 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -149,6 +150,42 @@ describe("archiveSelectedThreadEntries", () => { }); }); +describe("buildBulkTitleRegenerationContextMenuItem", () => { + it("counts only threads that can start a new regeneration", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 4, + actionableCount: 3, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerate titles (3)", + }); + }); + + it("shows a disabled progress item when every supported thread is pending", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 2, + actionableCount: 0, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerating… (2)", + disabled: true, + }); + }); + + it("omits the action when no selected environment supports it", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 0, + actionableCount: 0, + }), + ).toBeNull(); + }); +}); + describe("buildMultiSelectThreadContextMenuItems", () => { it("offers bulk archive with the selected count", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c94acfbc579..d11e06c8406 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -95,6 +95,24 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } +export function buildBulkTitleRegenerationContextMenuItem(input: { + supportedCount: number; + actionableCount: number; +}): ContextMenuItem<"regenerate-title"> | null { + if (input.supportedCount === 0) return null; + if (input.actionableCount === 0) { + return { + id: "regenerate-title", + label: `Regenerating… (${input.supportedCount})`, + disabled: true, + }; + } + return { + id: "regenerate-title", + label: `Regenerate titles (${input.actionableCount})`, + }; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e1bbbcd4987..d58d2b37cd1 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -106,6 +106,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -1938,6 +1939,18 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, + ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => thread.titleRegeneration == null, + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1955,6 +1968,7 @@ export default function SidebarV2() { }, ] : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1979,6 +1993,28 @@ export default function SidebarV2() { } return; } + if (clicked.value === "regenerate-title") { + for (const thread of regeneratableTitleThreads) { + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Success") continue; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread titles", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + clearSelection(); + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -2050,6 +2086,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + updateThreadMetadata, ], ); From 74d120aea28bbfb12b02dfa72b8c08180bc72333 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 04:16:19 -0700 Subject: [PATCH 13/13] fix(client): clear title regeneration on archive --- .../client-runtime/src/state/threadReducer.test.ts | 13 +++++++++++-- packages/client-runtime/src/state/threadReducer.ts | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 211f8748f4e..967c30960f9 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { CheckpointRef, + CommandId, EventId, MessageId, ProjectId, @@ -123,8 +124,15 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.archived / thread.unarchived", () => { - it("sets archivedAt", () => { - const result = applyThreadDetailEvent(baseThread, { + it("sets archivedAt and clears title regeneration", () => { + const regeneratingThread: OrchestrationThread = { + ...baseThread, + titleRegeneration: { + requestId: CommandId.make("regenerate-title"), + startedAt: "2026-04-01T02:00:00.000Z", + }, + }; + const result = applyThreadDetailEvent(regeneratingThread, { ...baseEventFields, sequence: 3, occurredAt: "2026-04-01T03:00:00.000Z", @@ -141,6 +149,7 @@ describe("applyThreadDetailEvent", () => { expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); + expect(result.thread.titleRegeneration).toBeNull(); } }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index fe637770804..6b04f094d82 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -94,6 +94,7 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: event.payload.archivedAt, + titleRegeneration: null, updatedAt: event.payload.updatedAt, }, };