mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
PR #428 added a General Settings zoom slider after the desktop shortcut work already introduced a native-first app zoom controller. Keeping both paths would create double scaling and stale UI state, so the slider now routes through the existing controller and store state while the app shell no longer applies a second CSS zoom. Constraint: UI zoom is device-local display state and should not be written into shared user settings. Rejected: Keep cc-haha-ui-zoom plus AppShell style.zoom | it conflicts with shortcut zoom and multiplies visual scale. Rejected: Persist zoom through /api/settings/user | it would sync display-specific state across machines. Confidence: high Scope-risk: moderate Directive: Keep app zoom behind desktop/src/lib/appZoom.ts; do not add another storage key or DOM zoom application point without migration and shortcut sync tests. Tested: cd desktop && bun run test -- --run src/lib/appZoom.test.ts src/hooks/useKeyboardShortcuts.test.tsx src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/lib/persistenceMigrations.test.ts src/lib/doctorRepair.test.ts Tested: bun run check:desktop Not-tested: Real Windows/Linux desktop runtime smoke.
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
import { useEffect, useRef, useState, type HTMLAttributes } from 'react'
|
|
import { Sidebar } from './Sidebar'
|
|
import { ContentRouter } from './ContentRouter'
|
|
import { ToastContainer } from '../shared/Toast'
|
|
import { UpdateChecker } from '../shared/UpdateChecker'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
import {
|
|
H5ConnectionRequiredError,
|
|
initializeDesktopServerUrl,
|
|
isH5ConnectionRequiredError,
|
|
isTauriRuntime,
|
|
} from '../../lib/desktopRuntime'
|
|
import { TabBar } from './TabBar'
|
|
import { StartupErrorView } from './StartupErrorView'
|
|
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { H5ConnectionView } from './H5ConnectionView'
|
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
|
import type { Tab } from '../../stores/tabStore'
|
|
|
|
function isChatTab(tab: Tab | undefined) {
|
|
return tab?.type === 'session'
|
|
}
|
|
|
|
export function AppShell() {
|
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
|
|
const [ready, setReady] = useState(false)
|
|
const [startupError, setStartupError] = useState<string | null>(null)
|
|
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
|
|
const [bootstrapNonce, setBootstrapNonce] = useState(0)
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
|
const t = useTranslation()
|
|
const tauriRuntime = isTauriRuntime()
|
|
const isMobileShell = useMobileViewport() && !tauriRuntime
|
|
const tabs = useTabStore((s) => s.tabs)
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const setActiveTab = useTabStore((s) => s.setActiveTab)
|
|
const activeSession = useSessionStore((s) =>
|
|
activeTabId ? s.sessions.find((session) => session.id === activeTabId) ?? null : null,
|
|
)
|
|
const wasMobileShellRef = useRef(false)
|
|
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
|
|
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
|
|
const isActiveChatTab = isChatTab(activeTab)
|
|
const mobileSessionTitle = activeSession?.title || activeTab?.title || t('session.untitled')
|
|
const mobileSessionUpdated = (() => {
|
|
if (!activeSession?.modifiedAt) return ''
|
|
const diff = Date.now() - new Date(activeSession.modifiedAt).getTime()
|
|
if (diff < 60000) return t('session.timeJustNow')
|
|
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
|
|
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
|
|
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
|
|
})()
|
|
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
|
|
isMobileShell && !effectiveSidebarOpen
|
|
? { 'aria-hidden': true, inert: '' }
|
|
: {}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const bootstrap = async () => {
|
|
if (!cancelled) {
|
|
setReady(false)
|
|
setStartupError(null)
|
|
setH5StartupError(null)
|
|
}
|
|
|
|
try {
|
|
await initializeDesktopServerUrl()
|
|
await fetchSettings()
|
|
|
|
if (!cancelled) {
|
|
setReady(true)
|
|
}
|
|
|
|
void (async () => {
|
|
await useTabStore.getState().restoreTabs()
|
|
if (cancelled) return
|
|
const { activeTabId: activeId, tabs } = useTabStore.getState()
|
|
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
|
|
if (activeId && activeTab?.type === 'session') {
|
|
useChatStore.getState().connectToSession(activeId)
|
|
}
|
|
})().catch(() => {})
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
if (!tauriRuntime && isH5ConnectionRequiredError(error)) {
|
|
setH5StartupError(error)
|
|
setStartupError(null)
|
|
} else {
|
|
setStartupError(error instanceof Error ? error.message : String(error))
|
|
setH5StartupError(null)
|
|
}
|
|
setReady(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
void bootstrap()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [bootstrapNonce, fetchSettings, tauriRuntime])
|
|
|
|
// Listen for macOS native menu navigation events (About / Settings)
|
|
useEffect(() => {
|
|
if (!tauriRuntime) return
|
|
let unlisten: (() => void) | undefined
|
|
import('@tauri-apps/api/event')
|
|
.then(({ listen }) =>
|
|
listen<string>('native-menu-navigate', (event) => {
|
|
const target = event.payload as SettingsTab | 'settings'
|
|
if (target === 'about') {
|
|
useUIStore.getState().setPendingSettingsTab('about')
|
|
}
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
}),
|
|
)
|
|
.then((fn) => { unlisten = fn })
|
|
.catch(() => {})
|
|
return () => { unlisten?.() }
|
|
}, [])
|
|
|
|
useKeyboardShortcuts()
|
|
|
|
useEffect(() => {
|
|
if (isMobileShell && !wasMobileShellRef.current) {
|
|
setMobileSidebarOpen(false)
|
|
setSidebarOpen(false)
|
|
}
|
|
if (!isMobileShell && wasMobileShellRef.current) {
|
|
setMobileSidebarOpen(false)
|
|
}
|
|
wasMobileShellRef.current = isMobileShell
|
|
}, [isMobileShell, setSidebarOpen])
|
|
|
|
useEffect(() => {
|
|
if (!ready || !isMobileShell) return
|
|
if (isChatTab(activeTab) || (!activeTab && !activeTabId)) return
|
|
const nextChatTab = tabs.find(isChatTab)
|
|
if (nextChatTab) {
|
|
setActiveTab(nextChatTab.sessionId)
|
|
return
|
|
}
|
|
useTabStore.setState({ activeTabId: null })
|
|
}, [activeTab, activeTabId, isMobileShell, ready, setActiveTab, tabs])
|
|
|
|
const setEffectiveSidebarOpen = (open: boolean) => {
|
|
if (isMobileShell) {
|
|
setMobileSidebarOpen(open)
|
|
setSidebarOpen(open)
|
|
return
|
|
}
|
|
setSidebarOpen(open)
|
|
}
|
|
|
|
const toggleEffectiveSidebar = () => {
|
|
if (isMobileShell) {
|
|
setEffectiveSidebarOpen(!mobileSidebarOpen)
|
|
return
|
|
}
|
|
toggleSidebar()
|
|
}
|
|
|
|
if (!tauriRuntime && h5StartupError) {
|
|
return (
|
|
<H5ConnectionView
|
|
initialServerUrl={h5StartupError.serverUrl}
|
|
error={h5StartupError.message}
|
|
onConnected={() => setBootstrapNonce((value) => value + 1)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (startupError) {
|
|
return <StartupErrorView error={startupError} />
|
|
}
|
|
|
|
if (!ready) {
|
|
return (
|
|
<div className="app-shell-viewport flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
|
|
{t('app.launching')}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
|
|
{isMobileShell && effectiveSidebarOpen ? (
|
|
<button
|
|
type="button"
|
|
data-testid="sidebar-backdrop"
|
|
className="app-shell-backdrop fixed inset-0 z-40 border-0 p-0"
|
|
aria-label={t('sidebar.collapse')}
|
|
onClick={() => setEffectiveSidebarOpen(false)}
|
|
/>
|
|
) : null}
|
|
<div
|
|
id="sidebar-shell"
|
|
data-testid="sidebar-shell"
|
|
data-state={effectiveSidebarOpen ? 'open' : 'closed'}
|
|
data-mobile={isMobileShell ? 'true' : 'false'}
|
|
className={`sidebar-shell${isMobileShell ? ' sidebar-shell--mobile' : ''}`}
|
|
{...sidebarHiddenProps}
|
|
>
|
|
{!isMobileShell || effectiveSidebarOpen ? (
|
|
<Sidebar isMobile={isMobileShell} onRequestClose={() => setEffectiveSidebarOpen(false)} />
|
|
) : null}
|
|
</div>
|
|
<main
|
|
id="content-area"
|
|
data-sidebar-state={effectiveSidebarOpen ? 'open' : 'closed'}
|
|
className={`min-w-0 flex-1 flex flex-col overflow-hidden${isMobileShell ? ' app-shell-main--mobile' : ''}`}
|
|
>
|
|
{isMobileShell ? (
|
|
<div
|
|
data-testid="mobile-session-header"
|
|
className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid="mobile-sidebar-toggle"
|
|
aria-controls="sidebar-shell"
|
|
aria-expanded={effectiveSidebarOpen}
|
|
aria-label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
|
|
onClick={toggleEffectiveSidebar}
|
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">
|
|
{effectiveSidebarOpen ? 'close' : 'menu'}
|
|
</span>
|
|
</button>
|
|
{isActiveChatTab ? (
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="truncate text-[15px] font-bold leading-tight text-[var(--color-text-primary)]">
|
|
{mobileSessionTitle}
|
|
</h1>
|
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
{activeTab?.status === 'running' ? (
|
|
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-secondary)]">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
|
{t('session.active')}
|
|
</span>
|
|
) : null}
|
|
{activeSession?.messageCount !== undefined && activeSession.messageCount > 0 ? (
|
|
<>
|
|
{activeTab?.status === 'running' ? <span aria-hidden="true">·</span> : null}
|
|
<span>{t('session.messages', { count: activeSession.messageCount })}</span>
|
|
</>
|
|
) : null}
|
|
{mobileSessionUpdated ? (
|
|
<>
|
|
{(activeTab?.status === 'running') || ((activeSession?.messageCount ?? 0) > 0) ? <span aria-hidden="true">·</span> : null}
|
|
<span className="truncate">{t('session.lastUpdated', { time: mobileSessionUpdated })}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{!isMobileShell ? <TabBar /> : null}
|
|
<ContentRouter />
|
|
</main>
|
|
<ToastContainer />
|
|
<UpdateChecker />
|
|
</div>
|
|
)
|
|
}
|