From 13622f91824b9fd7155b1dd9be12280b3e0d7c73 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:21:01 -0400 Subject: [PATCH 1/2] feat(settings): allow disabling AI-generated thread titles Add an environment-wide generateThreadTitles server setting, default on. When off, T3 makes no title-generation LLM call: the first-turn generation, refinement, and manual regeneration paths all return early, leaving the deterministic first-prompt title in place. Manual regeneration still completes through the existing path so no spinner or titleRegeneration state is left pending. The setting is surfaced in Settings > General > Text generation, is searchable, and hides the per-thread Regenerate title action when off. Conversation inference and provider/agent rename behavior are untouched. --- .../Layers/ProviderCommandReactor.test.ts | 235 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 17 +- .../components/settings/SettingsPanels.tsx | 29 +++ .../src/components/settings/settingsSearch.ts | 9 + apps/web/src/hooks/useThreadActionMenu.ts | 13 +- packages/contracts/src/settings.test.ts | 19 ++ packages/contracts/src/settings.ts | 9 + 7 files changed, 322 insertions(+), 9 deletions(-) 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.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6aeeb269f5a0..9ed6e44b1290 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -3187,6 +3187,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: "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/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), From baba583b7eac769da3727c372d54b26c4b85a5d3 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:07:16 -0400 Subject: [PATCH 2/2] fix(settings): restore AI thread titles with global defaults Global "Restore default settings" omitted generateThreadTitles, so turning AI thread titles off and then restoring defaults reported success while leaving the setting false. Its default is true. Add it to the changed-setting labels, the changed-setting memo dependencies, and the restore updateSettings patch, matching the other server settings. Cover the hook with a focused regression test proving the false -> true reset and the changed label. --- .../settings/SettingsPanels.restore.test.tsx | 106 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 5 + 2 files changed, 111 insertions(+) create mode 100644 apps/web/src/components/settings/SettingsPanels.restore.test.tsx 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 9ed6e44b1290..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,