Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions web/src/components/SessionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg
Expand Down Expand Up @@ -67,8 +55,12 @@ export function SessionHeader(props: {
onSessionDeleted?: () => 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)
Expand Down
19 changes: 4 additions & 15 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]'
Expand Down
33 changes: 33 additions & 0 deletions web/src/hooks/useGeneratedTitles.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(() => readGeneratedTitlesPreference())

const setGeneratedTitlesEnabled = (enabled: boolean) => {
setGeneratedTitlesEnabledState(enabled)
try {
localStorage.setItem(GENERATED_TITLES_KEY, enabled ? 'true' : 'false')
} catch {
// Ignore storage errors
}
}

return {
generatedTitlesEnabled,
setGeneratedTitlesEnabled
}
}
4 changes: 4 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,17 @@ 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',
'settings.about.title': 'About',
'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',
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,17 @@ 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': '自动检测',
'settings.about.title': '关于',
'settings.about.website': '官方网站',
'settings.about.appVersion': '应用版本',
'settings.about.protocolVersion': '协议版本',
'settings.common.enabled': '开启',
'settings.common.disabled': '关闭',

// Misc
'misc.noMachines': '无可用机器',
Expand Down
40 changes: 40 additions & 0 deletions web/src/lib/sessionTitle.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
22 changes: 22 additions & 0 deletions web/src/lib/sessionTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Session, SessionSummary } from '@/types/api'

type SessionLike = Pick<Session, 'id' | 'metadata'> | Pick<SessionSummary, 'id' | 'metadata'>

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)
}
27 changes: 26 additions & 1 deletion web/src/routes/settings/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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(<SettingsPage />)
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(<SettingsPage />)
fireEvent.click(screen.getAllByRole('button', { name: /Generated Titles/i })[0])

expect(setItem).toHaveBeenCalledWith('hapi:generated-titles-enabled', 'false')
})
})
22 changes: 22 additions & 0 deletions web/src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand Down Expand Up @@ -83,6 +84,7 @@ export default function SettingsPage() {
const voiceContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { appearance, setAppearance } = useAppearance()
const { generatedTitlesEnabled, setGeneratedTitlesEnabled } = useGeneratedTitles()

// Voice language state - read from localStorage
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
Expand Down Expand Up @@ -334,6 +336,26 @@ export default function SettingsPage() {
</div>
)}
</div>
<button
type="button"
onClick={() => setGeneratedTitlesEnabled(!generatedTitlesEnabled)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-pressed={generatedTitlesEnabled}
>
<div className="min-w-0">
<div className="text-[var(--app-fg)]">{t('settings.display.generatedTitles')}</div>
<div className="mt-0.5 text-xs text-[var(--app-hint)]">
{t('settings.display.generatedTitles.description')}
</div>
</div>
<span className={`ml-3 shrink-0 rounded-full px-2 py-1 text-xs font-medium ${
generatedTitlesEnabled
? 'bg-[var(--app-link)]/12 text-[var(--app-link)]'
: 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'
}`}>
{generatedTitlesEnabled ? t('settings.common.enabled') : t('settings.common.disabled')}
</span>
</button>
</div>

{/* Voice Assistant section */}
Expand Down