diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d9417bc75b..19b3ba831ece 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -10,8 +10,10 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderSetupError, + type ServerSettings, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import type { DeepPartial } from "@t3tools/shared/Struct"; import { ApprovalRequestId, CommandId, @@ -176,6 +178,7 @@ describe("ProviderCommandReactor", () => { readonly unreadableHistory?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly serverSettingsOverrides?: DeepPartial; readonly serverActivation?: Effect.Effect; readonly beforeReadySessionDispatch?: () => Effect.Effect; readonly beforeTurnStartDispatch?: () => Effect.Effect; @@ -490,7 +493,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettingsService.layerTest(input?.serverSettingsOverrides ?? {})), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), @@ -1849,6 +1852,236 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); + it("generates a first-turn title when the setting is left at its default", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + const harness = await createHarness({ initialTitle: seededTitle }); + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-default"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-default"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.title).toBe("Generated title"); + }); + + it("keeps the deterministic first-prompt title when AI title generation is disabled", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + const harness = await createHarness({ + initialTitle: seededTitle, + serverSettingsOverrides: { generateThreadTitles: false }, + }); + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-disabled"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-disabled"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.title).toBe(seededTitle); + }); + + it("clears manual title regeneration without generating when disabled", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + serverSettingsOverrides: { generateThreadTitles: false }, + }); + 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-disabled"), + 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-disabled-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-disabled-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-disabled-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-disabled-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-disabled-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-disabled-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-disabled"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.title).toBe("Investigate reconnect regressions"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("does not refine a vague generated title when disabled", async () => { + const harness = await createHarness({ + serverSettingsOverrides: { generateThreadTitles: false }, + }); + const threadId = ThreadId.make("thread-1"); + const turnId = TurnId.make("title-first-turn-disabled"); + const createdAt = "2026-01-01T00:00:01.000Z"; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Fix QR pairing expiry" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("title-turn-disabled"), + threadId, + message: { + messageId: MessageId.make("title-user-disabled"), + role: "user", + text: "Fix this", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }), + ); + await harness.drain(); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.title.generate.complete", + commandId: CommandId.make("initial-title-disabled"), + threadId, + expectedTitle: "Thread", + expectedVersion: null, + title: "Investigate issue", + needsRefinement: true, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("title-running-disabled"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: createdAt, + }, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("title-answer-disabled"), + threadId, + messageId: MessageId.make("title-assistant-disabled"), + turnId, + delta: "The QR pairing token expires before the phone redeems it.", + createdAt, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("title-ready-disabled"), + threadId, + createdAt, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.title).toBe("Investigate issue"); + }); + it("pins the first user message when regeneration context is truncated", 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 bdf4fe8e69d9..9023dcaab894 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -963,9 +963,9 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread( - input.threadId, - ); + const { textGenerationModelSelection: modelSelection, generateThreadTitles } = + yield* projectSettingsForThread(input.threadId); + if (!generateThreadTitles) return; const generated = yield* textGeneration .generateThreadTitle({ @@ -1022,6 +1022,8 @@ const make = Effect.gen(function* () { thread.session?.status !== "ready" ) return; + const { generateThreadTitles } = yield* projectSettingsForThread(threadId); + if (!generateThreadTitles) return; const detail = yield* resolveThreadDetail(threadId); if (!detail || detail.messages.filter((message) => message.role === "user").length !== 1) return; @@ -1061,10 +1063,11 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }) ?? process.cwd(); - const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( - yield* serverSettingsService.getSettings, - thread.projectId, - ).settings; + const { textGenerationModelSelection: modelSelection, generateThreadTitles } = + resolveProjectSettings(yield* serverSettingsService.getSettings, thread.projectId).settings; + if (!generateThreadTitles) { + return { _tag: "Completed", title: undefined } as const; + } const generated = yield* textGeneration.generateThreadTitle({ cwd, message, diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx new file mode 100644 index 000000000000..fb1783b6b345 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -0,0 +1,106 @@ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { StrictMode, useEffect, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ScopedSettingsPatch } from "./scopedSettings"; + +const state = vi.hoisted(() => ({ + settings: {} as Record, + confirm: vi.fn<() => Promise>(), + updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), + labels: [] as string[], + restore: null as (() => Promise) | null, +})); + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "system", + setTheme: () => true, + followSystem: true, + setFollowSystem: () => true, + setThemeHalf: () => true, + clearThemeHalves: () => true, + themeHalves: null, + }), + readThemePreference: () => "system", + readThemeHalves: () => null, + readAppearanceModePreference: () => "system", +})); + +vi.mock("./useScopedSettings", () => ({ + useScopedSettings: () => state.settings, + useUpdateScopedSettings: () => state.updateSettings, +})); + +vi.mock("../../localApi", () => ({ + readLocalApi: () => ({ dialogs: { confirm: state.confirm } }), + ensureLocalApi: () => ({ dialogs: { confirm: state.confirm } }), +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn() }, + stackedThreadToast: (toast: unknown) => toast, +})); + +import { useSettingsRestore } from "./SettingsPanels"; + +function Harness(): ReactNode { + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(); + useEffect(() => { + state.labels = changedSettingLabels; + state.restore = restoreDefaults; + }); + return null; +} + +let renderer: ReactTestRenderer | null; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, generateThreadTitles: false }; + state.confirm.mockReset().mockResolvedValue(true); + state.updateSettings.mockReset(); + act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +describe("global Restore device defaults", () => { + it("reports AI thread titles as changed and resets them to their default", async () => { + expect(state.labels).toContain("AI thread titles"); + + await act(async () => { + await state.restore?.(); + }); + + expect(state.confirm).toHaveBeenCalledTimes(1); + expect(state.updateSettings).toHaveBeenCalledTimes(1); + const patch = state.updateSettings.mock.calls[0]![0]; + expect(patch.generateThreadTitles).toBe(true); + expect(patch.generateThreadTitles).toBe(DEFAULT_UNIFIED_SETTINGS.generateThreadTitles); + }); + + it("does not report AI thread titles when the setting is already at its default", () => { + state.settings = { ...DEFAULT_UNIFIED_SETTINGS }; + act(() => { + renderer!.update( + + + , + ); + }); + + expect(state.labels).not.toContain("AI thread titles"); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6aeeb269f5a0..758f2a571583 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -623,6 +623,9 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), + ...(settings.generateThreadTitles !== DEFAULT_UNIFIED_SETTINGS.generateThreadTitles + ? ["AI thread titles"] + : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? ["Agent browser access"] @@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmThreadUnpin, + settings.generateThreadTitles, settings.composerCollapseOnScroll, settings.composerRichTextEnabled, settings.sendShortcut, @@ -788,6 +792,7 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + generateThreadTitles: DEFAULT_UNIFIED_SETTINGS.generateThreadTitles, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, @@ -3187,6 +3192,35 @@ export function GeneralSettingsPanel() { ) } /> + + + updateSettings({ + generateThreadTitles: DEFAULT_UNIFIED_SETTINGS.generateThreadTitles, + }) + } + /> + ) : null + } + control={ + + updateSettings({ generateThreadTitles: Boolean(checked) }) + } + aria-label="Generate thread titles with AI" + /> + } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7dbe..435250ec2617 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -454,6 +454,15 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "generate-thread-titles", + title: "Generate thread titles with AI", + to: "/settings/general", + scope: "environment-defaults", + searchTerms: [ + "ai title thread name automatic title first prompt llm rename generated naming disable off", + ], + }, { id: "diagnostics", title: "Diagnostics", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 8a24c5110fa7..209e6bcfb1e9 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; @@ -28,6 +29,7 @@ import { useProjects, } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; +import { serverEnvironment } from "../state/server"; import { readLocalApi } from "../localApi"; import { deriveLogicalProjectKeyFromSettings, @@ -71,6 +73,12 @@ export function useThreadActionMenu(input: { const router = useRouter(); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? null), + ); + // The server refuses title regeneration when this is off, so hide the + // action instead of offering one that silently does nothing. + const generateThreadTitles = serverConfig?.settings?.generateThreadTitles ?? true; const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const logicalProjectKeyByPhysicalKey = useMemo( () => @@ -134,7 +142,9 @@ export function useThreadActionMenu(input: { settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), pinning: readEnvironmentSupportsPinning(threadRef.environmentId), - titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), + titleRegeneration: + generateThreadTitles && + readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); @@ -324,6 +334,7 @@ export function useThreadActionMenu(input: { copyPathToClipboard, copyThreadIdToClipboard, deleteThread, + generateThreadTitles, handleNewThread, logicalProjectKeyByPhysicalKey, markThreadUnread, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 692edfb18a65..ab4b6b807a81 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -670,6 +670,25 @@ describe("ServerSettings thread settlement", () => { }); }); +describe("ServerSettings thread titles", () => { + it("defaults AI thread title generation on", () => { + expect(decodeServerSettings({}).generateThreadTitles).toBe(true); + expect(DEFAULT_SERVER_SETTINGS.generateThreadTitles).toBe(true); + }); + + it("accepts disabling AI thread title generation", () => { + expect(decodeServerSettings({ generateThreadTitles: false }).generateThreadTitles).toBe(false); + expect(decodeServerSettingsPatch({ generateThreadTitles: false }).generateThreadTitles).toBe( + false, + ); + }); + + it("rejects non-boolean thread title settings", () => { + expect(() => decodeServerSettings({ generateThreadTitles: "off" })).toThrow(); + expect(() => decodeServerSettingsPatch({ generateThreadTitles: "off" })).toThrow(); + }); +}); + describe("ClientSettings pull request merge methods", () => { it("defaults to no project overrides and accepts supported methods", () => { expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 504a9423f2a3..fc1d71c66322 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1234,6 +1234,14 @@ export const ServerSettings = Schema.Struct({ }), ), ), + /** + * Whether T3 may ask a model to name or rename a thread. On by default. + * When off, no title-generation LLM call is made: a new thread keeps the + * deterministic title derived from its first prompt, and refinement and + * manual regeneration are skipped. Environment-wide, not project-scoped, + * because the title path is server-owned and must not vary per project. + */ + generateThreadTitles: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1513,6 +1521,7 @@ export const ServerSettingsPatch = Schema.Struct({ worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + generateThreadTitles: Schema.optionalKey(Schema.Boolean), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({ mode: Schema.optionalKey(SourceControlWritingStyleMode),