From 38c20ef7fca872d6f7face1a0dae96c76aecaee7 Mon Sep 17 00:00:00 2001 From: webwww123 <197701451+webwww123@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:25:10 +0100 Subject: [PATCH] feat(web): add a setting to disable generated titles --- web/src/components/SessionHeader.tsx | 22 +++++--------- web/src/components/SessionList.tsx | 19 +++--------- web/src/hooks/useGeneratedTitles.ts | 33 +++++++++++++++++++++ web/src/lib/locales/en.ts | 4 +++ web/src/lib/locales/zh-CN.ts | 4 +++ web/src/lib/sessionTitle.test.ts | 40 ++++++++++++++++++++++++++ web/src/lib/sessionTitle.ts | 22 ++++++++++++++ web/src/routes/settings/index.test.tsx | 27 ++++++++++++++++- web/src/routes/settings/index.tsx | 22 ++++++++++++++ 9 files changed, 162 insertions(+), 31 deletions(-) create mode 100644 web/src/hooks/useGeneratedTitles.ts create mode 100644 web/src/lib/sessionTitle.test.ts create mode 100644 web/src/lib/sessionTitle.ts diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 3728b2670..b35a3fa04 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -2,26 +2,14 @@ import { useId, useMemo, useRef, useState } from 'react' import type { Session } from '@/types/api' import type { ApiClient } from '@/api/client' import { isTelegramApp } from '@/hooks/useTelegram' +import { useGeneratedTitles } from '@/hooks/useGeneratedTitles' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { getSessionTitle } from '@/lib/sessionTitle' import { useTranslation } from '@/lib/use-translation' -function getSessionTitle(session: Session): string { - if (session.metadata?.name) { - return session.metadata.name - } - if (session.metadata?.summary?.text) { - return session.metadata.summary.text - } - if (session.metadata?.path) { - const parts = session.metadata.path.split('/').filter(Boolean) - return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) - } - return session.id.slice(0, 8) -} - function FilesIcon(props: { className?: string }) { return ( void }) { const { t } = useTranslation() + const { generatedTitlesEnabled } = useGeneratedTitles() const { session, api, onSessionDeleted } = props - const title = useMemo(() => getSessionTitle(session), [session]) + const title = useMemo( + () => getSessionTitle(session, { allowGeneratedTitle: generatedTitlesEnabled }), + [generatedTitlesEnabled, session] + ) const worktreeBranch = session.metadata?.worktree?.branch const [menuOpen, setMenuOpen] = useState(false) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 69c71c37b..44dd6a241 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -7,6 +7,8 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { useGeneratedTitles } from '@/hooks/useGeneratedTitles' +import { getSessionTitle } from '@/lib/sessionTitle' import { useTranslation } from '@/lib/use-translation' type SessionGroup = { @@ -121,20 +123,6 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) { ) } -function getSessionTitle(session: SessionSummary): string { - if (session.metadata?.name) { - return session.metadata.name - } - if (session.metadata?.summary?.text) { - return session.metadata.summary.text - } - if (session.metadata?.path) { - const parts = session.metadata.path.split('/').filter(Boolean) - return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) - } - return session.id.slice(0, 8) -} - function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null { if (!session.todoProgress) return null if (session.todoProgress.completed === session.todoProgress.total) return null @@ -169,6 +157,7 @@ function SessionItem(props: { selected?: boolean }) { const { t } = useTranslation() + const { generatedTitlesEnabled } = useGeneratedTitles() const { session: s, onSelect, showPath = true, api, selected = false } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) @@ -197,7 +186,7 @@ function SessionItem(props: { threshold: 500 }) - const sessionName = getSessionTitle(s) + const sessionName = getSessionTitle(s, { allowGeneratedTitle: generatedTitlesEnabled }) const statusDotClass = s.active ? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' diff --git a/web/src/hooks/useGeneratedTitles.ts b/web/src/hooks/useGeneratedTitles.ts new file mode 100644 index 000000000..e920d570e --- /dev/null +++ b/web/src/hooks/useGeneratedTitles.ts @@ -0,0 +1,33 @@ +import { useState } from 'react' + +const GENERATED_TITLES_KEY = 'hapi:generated-titles-enabled' + +function readGeneratedTitlesPreference(): boolean { + if (typeof window === 'undefined') return true + try { + return localStorage.getItem(GENERATED_TITLES_KEY) !== 'false' + } catch { + return true + } +} + +export function useGeneratedTitles(): { + generatedTitlesEnabled: boolean + setGeneratedTitlesEnabled: (enabled: boolean) => void +} { + const [generatedTitlesEnabled, setGeneratedTitlesEnabledState] = useState(() => readGeneratedTitlesPreference()) + + const setGeneratedTitlesEnabled = (enabled: boolean) => { + setGeneratedTitlesEnabledState(enabled) + try { + localStorage.setItem(GENERATED_TITLES_KEY, enabled ? 'true' : 'false') + } catch { + // Ignore storage errors + } + } + + return { + generatedTitlesEnabled, + setGeneratedTitlesEnabled + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9c67e8d25..a0ea168ef 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -254,6 +254,8 @@ export default { 'settings.display.appearance.dark': 'Dark', 'settings.display.appearance.light': 'Light', 'settings.display.fontSize': 'Font Size', + 'settings.display.generatedTitles': 'Generated Titles', + 'settings.display.generatedTitles.description': 'Allow AI tools to keep updating session titles automatically.', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', 'settings.voice.autoDetect': 'Auto-detect', @@ -261,6 +263,8 @@ export default { 'settings.about.website': 'Website', 'settings.about.appVersion': 'App Version', 'settings.about.protocolVersion': 'Protocol Version', + 'settings.common.enabled': 'Enabled', + 'settings.common.disabled': 'Disabled', // Misc 'misc.noMachines': 'No machines available', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fa218ed91..f147d1f09 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -256,6 +256,8 @@ export default { 'settings.display.appearance.dark': '深色', 'settings.display.appearance.light': '浅色', 'settings.display.fontSize': '字体大小', + 'settings.display.generatedTitles': '自动标题', + 'settings.display.generatedTitles.description': '允许 AI 工具自动持续更新会话标题。', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', 'settings.voice.autoDetect': '自动检测', @@ -263,6 +265,8 @@ export default { 'settings.about.website': '官方网站', 'settings.about.appVersion': '应用版本', 'settings.about.protocolVersion': '协议版本', + 'settings.common.enabled': '开启', + 'settings.common.disabled': '关闭', // Misc 'misc.noMachines': '无可用机器', diff --git a/web/src/lib/sessionTitle.test.ts b/web/src/lib/sessionTitle.test.ts new file mode 100644 index 000000000..9443ef9dc --- /dev/null +++ b/web/src/lib/sessionTitle.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { getSessionTitle } from './sessionTitle' + +describe('getSessionTitle', () => { + it('prefers a manually assigned session name', () => { + expect(getSessionTitle({ + id: 'session-1', + metadata: { + path: '/root/project-a', + name: 'Manual name' + } + })).toBe('Manual name') + }) + + it('uses generated summary text when automatic titles are enabled', () => { + expect(getSessionTitle({ + id: 'session-1', + metadata: { + path: '/root/project-a', + summary: { + text: 'Generated title', + updatedAt: 1 + } + } + }, { allowGeneratedTitle: true })).toBe('Generated title') + }) + + it('ignores generated summary text when automatic titles are disabled', () => { + expect(getSessionTitle({ + id: 'session-1', + metadata: { + path: '/root/project-a', + summary: { + text: 'Generated title', + updatedAt: 1 + } + } + }, { allowGeneratedTitle: false })).toBe('project-a') + }) +}) diff --git a/web/src/lib/sessionTitle.ts b/web/src/lib/sessionTitle.ts new file mode 100644 index 000000000..9e2e07d40 --- /dev/null +++ b/web/src/lib/sessionTitle.ts @@ -0,0 +1,22 @@ +import type { Session, SessionSummary } from '@/types/api' + +type SessionLike = Pick | Pick + +export function getSessionTitle( + session: SessionLike, + opts: { allowGeneratedTitle?: boolean } = {} +): string { + const allowGeneratedTitle = opts.allowGeneratedTitle ?? true + + if (session.metadata?.name) { + return session.metadata.name + } + if (allowGeneratedTitle && session.metadata?.summary?.text) { + return session.metadata.summary.text + } + if (session.metadata?.path) { + const parts = session.metadata.path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) + } + return session.id.slice(0, 8) +} diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 224868319..5023f7c93 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { I18nContext, I18nProvider } from '@/lib/i18n-context' import { en } from '@/lib/locales' import { PROTOCOL_VERSION } from '@hapi/protocol' import SettingsPage from './index' +vi.mock('@hapi/protocol', () => ({ + PROTOCOL_VERSION: '1' +})) + // Mock the router hooks vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -121,4 +125,25 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.display.appearance') expect(calledKeys).toContain('settings.display.appearance.system') }) + + it('renders the generated titles preference', () => { + renderWithProviders() + expect(screen.getAllByText('Generated Titles').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Allow AI tools to keep updating session titles automatically.').length).toBeGreaterThanOrEqual(1) + }) + + it('updates localStorage when toggling generated titles', () => { + const setItem = vi.fn() + const localStorageMock = { + getItem: vi.fn((key: string) => key === 'hapi:generated-titles-enabled' ? 'true' : 'en'), + setItem, + removeItem: vi.fn(), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + + renderWithProviders() + fireEvent.click(screen.getAllByRole('button', { name: /Generated Titles/i })[0]) + + expect(setItem).toHaveBeenCalledWith('hapi:generated-titles-enabled', 'false') + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 3c7be6720..a443cccc5 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -4,6 +4,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' +import { useGeneratedTitles } from '@/hooks/useGeneratedTitles' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -83,6 +84,7 @@ export default function SettingsPage() { const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() const { appearance, setAppearance } = useAppearance() + const { generatedTitlesEnabled, setGeneratedTitlesEnabled } = useGeneratedTitles() // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { @@ -334,6 +336,26 @@ export default function SettingsPage() { )} +
+
{t('settings.display.generatedTitles')}
+
+ {t('settings.display.generatedTitles.description')} +
+
+ + {generatedTitlesEnabled ? t('settings.common.enabled') : t('settings.common.disabled')} + + {/* Voice Assistant section */}