diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9313aef299881..05cc2ecc530f3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -23,13 +23,22 @@ const state = vi.hoisted(() => ({ requests: [] as { action: string; environmentId: string; - input: { threadId: string; orderKey?: string; snoozedUntil?: string; reason?: string }; + input: { + threadId: string; + orderKey?: string; + snoozedUntil?: string; + reason?: string; + commandId?: string; + }; }[], dialogs: [] as { onConfirm: () => void }[], alerts: [] as { title: string; + message?: string; buttons?: { text: string; onPress?: () => void }[]; }[], + titleFailure: null as string | null, + watchedTitleRequests: [] as string[], afterRequest: undefined as (() => void) | undefined, })); @@ -48,8 +57,8 @@ vi.mock("expo-haptics", () => ({ ImpactFeedbackStyle: { Light: "light" }, })); vi.mock("../../components/ui/Dialog", () => ({ - alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => - state.alerts.push({ title, buttons }), + alert: (title: string, message: string, buttons?: { text: string; onPress?: () => void }[]) => + state.alerts.push({ title, message, buttons }), confirm: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), })); vi.mock("../archive/useArchivedThreadSnapshots", () => ({ @@ -119,7 +128,12 @@ vi.mock("../../state/thread-order", () => ({ }; }, })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "command" })); vi.mock("../../state/threads", () => ({ + watchThreadTitleRegeneration: (_ref: unknown, requestId: string) => { + state.watchedTitleRequests.push(requestId); + return { failure: Promise.resolve(state.titleFailure), cancel: () => {} }; + }, environmentThreadShells: { threadShellsAtom: "thread-shells", navigationThreadShellsAtom: "thread-shells", @@ -199,6 +213,8 @@ beforeEach(() => { state.shells = []; state.dialogs = []; state.alerts = []; + state.titleFailure = null; + state.watchedTitleRequests = []; state.afterRequest = undefined; }); @@ -242,6 +258,23 @@ describe("thread list operation permissions", () => { }); }); +describe("thread title regeneration", () => { + it("alerts why the request it dispatched kept the title", async () => { + state.titleFailure = "Claude CLI command failed: OAuth session expired"; + + await useThreadListActions().regenerateThreadTitle(makeThread()); + await Promise.resolve(); + + expect(state.watchedTitleRequests).toEqual([state.requests[0]?.input.commandId]); + expect(state.alerts).toEqual([ + expect.objectContaining({ + title: "Could not regenerate title", + message: "Claude CLI command failed: OAuth session expired", + }), + ]); + }); +}); + describe("pinned thread operation permissions", () => { it("checks every materialization target before writing any keys", async () => { const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3893569c6886d..cc269821e735d 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -3,7 +3,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, CommandId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useRef } from "react"; @@ -11,13 +11,18 @@ import { useRef } from "react"; import { withThreadDismissal } from "./thread-dismissal"; import * as Dialog from "../../components/ui/Dialog"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { uuidv4 } from "../../lib/uuid"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; -import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { + environmentThreadShells, + threadEnvironment, + watchThreadTitleRegeneration, +} from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; import { @@ -508,10 +513,15 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); + const commandId = CommandId.make(uuidv4()); + const watch = watchThreadTitleRegeneration( + { environmentId: thread.environmentId, threadId: thread.id }, + commandId, + ); const succeeded = await (async () => { const result = await updateThreadMetadata({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id, regenerateTitle: true, commandId }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -525,7 +535,16 @@ export function useThreadListActions(): { } return true; })(); - titleRegenerationInFlightThreadKeys.current.delete(key); + if (succeeded) { + void watch.failure + .then((failure) => { + if (failure !== null) Dialog.alert("Could not regenerate title", failure); + }) + .finally(() => titleRegenerationInFlightThreadKeys.current.delete(key)); + } else { + watch.cancel(); + titleRegenerationInFlightThreadKeys.current.delete(key); + } return succeeded; }; const renameThread = (thread: EnvironmentThreadShell) => { diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index d6e784377cca6..42edfc42b8c67 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -9,8 +9,10 @@ import { createOlderSettledThreadAtoms, createThreadEnvironmentAtoms, createThreadFamilyAtoms, + type TitleRegenerationWatch, + watchTitleRegeneration, } from "@t3tools/client-runtime/state/threads"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; @@ -46,6 +48,19 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom, }); + +/** Start before dispatching the request; see `watchTitleRegeneration`. */ +export function watchThreadTitleRegeneration( + ref: ScopedThreadRef, + requestId: string, +): TitleRegenerationWatch { + return watchTitleRegeneration({ + registry: appAtomRegistry, + atom: environmentThreadShells.threadShellAtom(ref), + requestId, + }); +} + export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 401b91f24e0c8..03a9c4ab3308f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -860,6 +860,7 @@ function threadDetail( requestId: projection.thread.titleRegeneration.requestId, startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt), }, + titleRegenerationFailure: projection.thread.titleRegenerationFailure ?? null, branch: projection.thread.branch, worktreePath: projection.thread.worktreePath, parentThreadId: projection.thread.lineage.parentThreadId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 07c1a544798be..47a13472ae48b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4110,9 +4110,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ], }), ...(command.regenerateTitle === true - ? { titleRegeneration: { requestId: command.commandId, startedAt: now } } + ? { + titleRegeneration: { requestId: command.commandId, startedAt: now }, + titleRegenerationFailure: null, + } : command.regenerateTitle === false || command.title !== undefined - ? { titleRegeneration: null } + ? { titleRegeneration: null, titleRegenerationFailure: null } : {}), updatedAt: now, }; @@ -4345,6 +4348,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, ...(command.title === undefined ? {} : { title: command.title }), titleRegeneration: null, + titleRegenerationFailure: + command.failure === undefined + ? null + : { requestId: command.requestId, message: command.failure }, updatedAt: now, } : thread; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0c4976dc90598..c1f1eef409c90 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1709,6 +1709,7 @@ export function threadShellFromProjection( lastVisitedAt: projection.thread.lastVisitedAt, opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, + titleRegenerationFailure: projection.thread.titleRegenerationFailure ?? null, limitRecovery: projection.thread.limitRecovery ?? null, deletedAt: projection.thread.deletedAt, }; @@ -1996,6 +1997,7 @@ function shellFromState(input: { lastVisitedAt: input.state.thread.lastVisitedAt, opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, + titleRegenerationFailure: input.state.thread.titleRegenerationFailure ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, deletedAt: input.state.thread.deletedAt, }; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index c54e6e1f4815d..0793f1a4d7dc7 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -515,10 +516,16 @@ describe("ThreadTitleRegenerationService", () => { }), ); - it.effect("clears the marker and keeps the title when generation fails", () => + it.effect("keeps the title and records why when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ - generateTitle: () => Effect.die(new Error("model unavailable")), + generateTitle: () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Claude CLI command failed: OAuth session expired", + }), + ), }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -540,6 +547,14 @@ describe("ThreadTitleRegenerationService", () => { const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.thread.title, "Seed title"); assert.isNotOk(projection.thread.titleRegeneration); + assert.deepEqual(projection.thread.titleRegenerationFailure, { + requestId, + message: "Claude CLI command failed: OAuth session expired", + }); + + yield* armRegeneration({ command: "command:title:failure:2", threadId }); + const retried = yield* threads.getThreadProjection(threadId); + assert.isNotOk(retried.thread.titleRegenerationFailure); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 395e8768996e8..026e47d5801cb 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,7 +1,9 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; +import { truncate } from "@t3tools/shared/String"; import { CommandId, + TextGenerationError, type ChatAttachment, type MessageId, type OrchestrationV2AppThread, @@ -17,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -46,6 +49,20 @@ export class ThreadTitleRegenerationService extends Context.Service< } >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} +type TitleOutcome = + | { readonly type: "stale" } + | { readonly type: "complete"; readonly title?: string; readonly failure?: string }; + +const GENERIC_FAILURE = "The title could not be generated."; +const isTextGenerationError = Schema.is(TextGenerationError); + +function failureMessage(cause: Cause.Cause): string { + const error = Cause.findErrorOption(cause); + const detail = + Option.isSome(error) && isTextGenerationError(error.value) ? error.value.detail.trim() : ""; + return detail.length === 0 ? GENERIC_FAILURE : truncate(detail, 500); +} + const make = Effect.gen(function* () { const threads = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectStore.ProjectStoreV2; @@ -57,6 +74,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: CommandId; readonly title?: string; + readonly failure?: string; }) => threads .dispatch({ @@ -65,6 +83,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title === undefined ? {} : { title: input.title }), + ...(input.failure === undefined ? {} : { failure: input.failure }), }) .pipe(Effect.asVoid); @@ -110,9 +129,7 @@ const make = Effect.gen(function* () { const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn( "ThreadTitleRegenerationService.execute", )(function* (input) { - const outcome: - | { readonly type: "stale" } - | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () { + const outcome: TitleOutcome = yield* Effect.gen(function* () { const projection = yield* threads.getThreadRecords( input.threadId, ["messages"], @@ -194,7 +211,13 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, cause, - }).pipe(Effect.as({ type: "complete" as const })), + }).pipe( + Effect.as({ + type: "complete" as const, + // A first title falls back to the prompt; only a requested regeneration reports. + ...(input.kind.type === "regenerate" ? { failure: failureMessage(cause) } : {}), + }), + ), ), ); @@ -204,6 +227,7 @@ const make = Effect.gen(function* () { yield* complete({ ...input, ...(outcome.title === undefined ? {} : { title: outcome.title }), + ...(outcome.failure === undefined ? {} : { failure: outcome.failure }), }); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 86c6cb9a8cfef..0fe0ccfe3e7f3 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -213,6 +213,35 @@ function withFakeCodexEnv( } it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { + it.effect("reports the final CLI error instead of its echoed prompt", () => + withFakeCodexEnv( + { + output: "", + exitCode: 1, + stderr: [ + "OpenAI Codex v0.164.0", + "user", + "Regenerate the thread title. ".repeat(40), + "ERROR: Reconnecting... 5/5", + "ERROR: unknown provider for model unavailable-model", + ].join("\n"), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + expect(error.detail).toBe( + "Codex CLI command failed: unknown provider for model unavailable-model", + ); + }), + ), + ); + it.effect.each(["exec", "mcp list"] as const)( "kills a stalled %s CLI when its request is interrupted", (command) => diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index cbf095b303abc..1dd20b3273007 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -353,7 +353,12 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); if (exitCode !== 0) { - const stderrDetail = stderr.trim(); + const stderrDetail = + stderr + .split("\n") + .findLast((line) => line.startsWith("ERROR: ")) + ?.slice("ERROR: ".length) + .trim() || stderr.trim(); const stdoutDetail = stdout.trim(); const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; return yield* new TextGenerationError({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 661b50a00b248..fafebbb3c99cf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -54,6 +54,7 @@ import { } from "@t3tools/client-runtime/environment"; import { AuthOrchestrationOperateScope, + CommandId, ProjectId, type ScopedThreadRef, type ThreadId, @@ -96,7 +97,7 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; -import { isMacPlatform } from "~/lib/utils"; +import { isMacPlatform, randomUUID } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; @@ -144,6 +145,7 @@ import { useProjects, useServerConfigs, useQuietThreadShells, + watchThreadTitleRegeneration, } from "../state/entities"; import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -2434,11 +2436,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } if (clicked.value === "regenerate-title") { for (const thread of regeneratableTitleThreads) { + const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration( + scopeThreadRef(thread.environmentId, thread.id), + commandId, + ); const result = await updateThreadMetadata({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id, regenerateTitle: true, commandId }, }); - if (result._tag === "Success") continue; + if (result._tag === "Success") { + void watch.failure.then((failure) => { + if (failure === null) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: `${thread.title}: ${failure}`, + }), + ); + }); + continue; + } + watch.cancel(); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2764,20 +2784,36 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; case "regenerate-title": { if (isRegeneratingTitle) return; + const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration(threadRef, commandId); const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, + input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + if (result._tag === "Failure") { + watch.cancel(); + if (!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; + } + void watch.failure.then((failure) => { + if (failure === null) return; toastManager.add( stackedThreadToast({ type: "error", title: "Failed to regenerate thread title", - description: error instanceof Error ? error.message : "An error occurred.", + description: failure, }), ); - } + }); return; } case "mark-unread": diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3c8255b04c088..5ca2a588f4b43 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -35,6 +35,7 @@ function recordEffect(action: string) { } vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); +vi.mock("../lib/utils", () => ({ randomUUID: () => "command" })); vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index f2d5509c9488f..088cc012cfdd4 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -10,6 +10,7 @@ import { import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope, + CommandId, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; @@ -23,6 +24,7 @@ import { } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; +import { randomUUID } from "../lib/utils"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -32,6 +34,7 @@ import { readEnvironmentSupportsAutoSettleOptOut, readThreadShell, useProjects, + watchThreadTitleRegeneration, } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; @@ -250,15 +253,28 @@ export function useThreadActionMenu(input: { case "rename": onStartRename(); return; - case "regenerate-title": + case "regenerate-title": { if (isRegeneratingTitle) return; - await reportFailure("Failed to regenerate thread title", () => - updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }), - ); + const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration(threadRef, commandId); + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, + }); + if (result._tag === "Failure") { + watch.cancel(); + if (!isAtomCommandInterrupted(result)) { + failureToast("Failed to regenerate thread title", squashAtomCommandFailure(result)); + } + return; + } + void watch.failure.then((failure) => { + if (failure !== null) { + failureToast("Failed to regenerate thread title", new Error(failure)); + } + }); return; + } case "mark-unread": markThreadUnread(threadRef); return; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..c1db44640b51c 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -11,6 +11,8 @@ import { EMPTY_THREAD_HISTORY_META, type EnvironmentThreadStatus, type ThreadHistoryMeta, + type TitleRegenerationWatch, + watchTitleRegeneration, } from "@t3tools/client-runtime/state/threads"; import { arrayElementsEqual, @@ -338,6 +340,18 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } +/** Start before dispatching the request; see `watchTitleRegeneration`. */ +export function watchThreadTitleRegeneration( + ref: ScopedThreadRef, + requestId: string, +): TitleRegenerationWatch { + return watchTitleRegeneration({ + registry: appAtomRegistry, + atom: environmentThreadShells.threadShellAtom(ref), + requestId, + }); +} + export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean { const configs = useServerConfigs(); return ( diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 098b73141e3c9..7c97a75761855 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -195,6 +195,10 @@ export interface EnvironmentThreadShell { /** Server-tracked open history for search ranking. */ readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null; readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; + readonly titleRegenerationFailure?: { + readonly requestId: string; + readonly message: string; + } | null; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; } @@ -349,6 +353,7 @@ export function presentThreadShell( requestId: thread.titleRegeneration.requestId, startedAt: iso(thread.titleRegeneration.startedAt), }, + titleRegenerationFailure: thread.titleRegenerationFailure ?? null, deletedAt: nullableIso(thread.deletedAt), source: thread, }; diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 39607575627dd..f2a6425a2685e 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -16,7 +16,12 @@ import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import { isThreadWorking } from "./threadInbox.ts"; -import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; +import { + watchTitleRegeneration, + childThreadInputsTitle, + createEnvironmentThreadShellAtoms, +} from "./threadShell.ts"; const environmentId = EnvironmentId.make("environment-v2"); const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); @@ -634,3 +639,42 @@ describe("v2 thread shell lists", () => { } }); }); + +describe("watchTitleRegeneration", () => { + const shell = ( + fields: Pick, + ) => fields as EnvironmentThreadShell; + const pending = (requestId: string) => + shell({ titleRegeneration: { requestId, startedAt: "2026-11-03T00:00:00.000Z" } }); + + it.each([ + { + name: "reports its own failure", + after: shell({ titleRegenerationFailure: { requestId: "a", message: "Login expired" } }), + expected: "Login expired", + }, + { name: "settles when the title lands", after: shell({}), expected: null }, + { + name: "ignores a newer request's failure", + after: shell({ titleRegenerationFailure: { requestId: "b", message: "Login expired" } }), + expected: null, + }, + ])("$name", async ({ after, expected }) => { + const registry = AtomRegistry.make(); + const atom = Atom.make(shell({})).pipe(Atom.keepAlive); + const watch = watchTitleRegeneration({ registry, atom, requestId: "a" }); + registry.set(atom, pending("a")); + registry.set(atom, after); + await expect(watch.failure).resolves.toBe(expected); + registry.dispose(); + }); + + it("settles when cancelled because the request was never accepted", async () => { + const registry = AtomRegistry.make(); + const atom = Atom.make(shell({})).pipe(Atom.keepAlive); + const watch = watchTitleRegeneration({ registry, atom, requestId: "a" }); + watch.cancel(); + await expect(watch.failure).resolves.toBeNull(); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index f5ced761c5665..0fbbfe618248f 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -12,7 +12,7 @@ import { OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { Atom } from "effect/reactivity"; +import { Atom, type AtomRegistry } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; @@ -200,6 +200,51 @@ function sameApartFromStreaming( ); } +export interface TitleRegenerationWatch { + /** The server's reason when the request kept the title; null when it landed or stopped mattering. */ + readonly failure: Promise; + readonly cancel: () => void; +} + +/** + * Watches one title regeneration request. Start it before dispatching: a request that + * finishes before the watch starts never shows as pending, so its success goes unseen. + */ +export function watchTitleRegeneration(input: { + readonly registry: AtomRegistry.AtomRegistry; + readonly atom: Atom.Atom; + readonly requestId: string; + readonly timeoutMs?: number; +}): TitleRegenerationWatch { + let finish: (failure: string | null) => void = () => {}; + const failure = new Promise((resolve) => { + let pending = false; + let settled = false; + let unsubscribe: (() => void) | null = null; + finish = (value) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timeout); + unsubscribe?.(); + resolve(value); + }; + const inspect = (shell: EnvironmentThreadShell | null) => { + if (shell === null) return finish(null); + if (shell.titleRegenerationFailure?.requestId === input.requestId) { + return finish(shell.titleRegenerationFailure.message); + } + if (shell.titleRegeneration?.requestId === input.requestId) pending = true; + else if (pending) finish(null); + }; + // @effect-diagnostics-next-line globalTimers:off - Client UI waits on atoms, outside an Effect runtime. + const timeout = globalThis.setTimeout(() => finish(null), input.timeoutMs ?? 180_000); + unsubscribe = input.registry.subscribe(input.atom, inspect); + if (settled) unsubscribe(); + else inspect(input.registry.get(input.atom)); + }); + return { failure, cancel: () => finish(null) }; +} + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b1fd02b3d3a07..5cad344c5fc94 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -41,6 +41,7 @@ import { TrimmedNonEmptyString, TurnItemId, } from "./baseSchemas.ts"; +import { ThreadTitleRegenerationFailure } from "./threadTitle.ts"; import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; @@ -505,6 +506,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ }), ), ), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), rollbackRequestId: Schema.optional(CommandId), rollbackFailure: Schema.optional( Schema.NullOr( @@ -2178,6 +2180,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ }), ), ), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), deletedAt: Schema.NullOr(Schema.DateTimeUtc), }); export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; @@ -3157,6 +3160,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(TrimmedNonEmptyString), }), Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d1bb9961a0c68..080f3254328e6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -34,7 +34,7 @@ import { } from "./threadPullRequest.ts"; import { secretRequestPageUrl } from "./secretRequest.ts"; import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts"; -import { ThreadTitleRegeneration } from "./threadTitle.ts"; +import { ThreadTitleRegeneration, ThreadTitleRegenerationFailure } from "./threadTitle.ts"; import { OrchestrationV2Actor, OrchestrationV2CreationSource, @@ -447,6 +447,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), parentThreadId: Schema.NullOr(ThreadId), diff --git a/packages/contracts/src/threadTitle.ts b/packages/contracts/src/threadTitle.ts index 8f14f431ecff2..01edc1821cb76 100644 --- a/packages/contracts/src/threadTitle.ts +++ b/packages/contracts/src/threadTitle.ts @@ -1,9 +1,16 @@ import * as Schema from "effect/Schema"; -import { CommandId, IsoDateTime } from "./baseSchemas.ts"; +import { CommandId, IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const ThreadTitleRegeneration = Schema.Struct({ requestId: CommandId, startedAt: IsoDateTime, }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + +/** Why the latest title regeneration request kept the current title. */ +export const ThreadTitleRegenerationFailure = Schema.Struct({ + requestId: CommandId, + message: TrimmedNonEmptyString, +}); +export type ThreadTitleRegenerationFailure = typeof ThreadTitleRegenerationFailure.Type;