diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 4b693ada7..fd5096dc6 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { ModelMode } from './modes' +import type { ModelMode, PermissionMode } from './modes' import type { Session, WorktreeMetadata } from './schemas' export type SessionSummaryMetadata = { @@ -19,6 +19,7 @@ export type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number + permissionMode?: PermissionMode modelMode?: ModelMode } @@ -48,6 +49,7 @@ export function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, + permissionMode: session.permissionMode, modelMode: session.modelMode } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 1a038f6cb..3d4984056 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' -import { initializeTheme } from '@/hooks/useTheme' +import { initializeTheme, useTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' @@ -41,6 +41,7 @@ export function App() { } function AppInner() { + useTheme() const { t } = useTranslation() const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 3728b2670..21b8f23a4 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -1,12 +1,14 @@ import { useId, useMemo, useRef, useState } from 'react' import type { Session } from '@/types/api' import type { ApiClient } from '@/api/client' +import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { isTelegramApp } from '@/hooks/useTelegram' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { useTranslation } from '@/lib/use-translation' +import { getFlavorBadgeClass, PERMISSION_TONE_BADGE } from '@/lib/agentFlavorUtils' function getSessionTitle(session: Session): string { if (session.metadata?.name) { @@ -70,6 +72,19 @@ export function SessionHeader(props: { const { session, api, onSessionDeleted } = props const title = useMemo(() => getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch + const flavor = session.metadata?.flavor?.trim() ?? null + const flavorLabel = flavor || 'unknown' + const flavorBadgeClass = getFlavorBadgeClass(flavor) + const permMode = session.permissionMode + && session.permissionMode !== 'default' + && isPermissionModeAllowedForFlavor(session.permissionMode, flavor) + ? session.permissionMode + : null + const permissionLabel = permMode ? getPermissionModeLabel(permMode).toLowerCase() : null + const permissionBadgeClass = permMode + ? PERMISSION_TONE_BADGE[getPermissionModeTone(permMode)] + : null + const showModelModeBadge = !flavor || flavor === 'claude' const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -128,21 +143,29 @@ export function SessionHeader(props: { - {/* Session info - two lines: title and path */} + {/* Session info - two lines: title and badges */}
{title}
-
- - - {session.metadata?.flavor?.trim() || 'unknown'} - - - {t('session.item.modelMode')}: {session.modelMode || 'default'} +
+ + {flavorLabel} + {permissionLabel && permissionBadgeClass ? ( + + {permissionLabel} + + ) : null} + {showModelModeBadge ? ( + + {session.modelMode || 'default'} + + ) : null} {worktreeBranch ? ( - {t('session.item.worktree')}: {worktreeBranch} + + {worktreeBranch} + ) : null}
diff --git a/web/src/components/SessionList.test.tsx b/web/src/components/SessionList.test.tsx new file mode 100644 index 000000000..6660eba92 --- /dev/null +++ b/web/src/components/SessionList.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { SessionSummary } from '@/types/api' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionList } from './SessionList' + +vi.mock('@/hooks/useLongPress', () => ({ + useLongPress: ({ onClick }: { onClick: () => void }) => ({ onClick }) +})) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ haptic: { impact: vi.fn() } }) +})) + +vi.mock('@/hooks/mutations/useSessionActions', () => ({ + useSessionActions: () => ({ + archiveSession: vi.fn(), + renameSession: vi.fn(), + deleteSession: vi.fn(), + isPending: false + }) +})) + +vi.mock('@/components/SessionActionMenu', () => ({ + SessionActionMenu: () => null +})) + +vi.mock('@/components/RenameSessionDialog', () => ({ + RenameSessionDialog: () => null +})) + +vi.mock('@/components/ui/ConfirmDialog', () => ({ + ConfirmDialog: () => null +})) + +function makeSession(overrides: Partial): SessionSummary { + const id = overrides.id ?? 'session-1' + return { + id, + active: overrides.active ?? true, + thinking: overrides.thinking ?? false, + activeAt: overrides.activeAt ?? 1, + updatedAt: overrides.updatedAt ?? 1, + metadata: overrides.metadata ?? { + name: id, + path: '/repo/app', + machineId: 'machine-1', + flavor: 'claude', + summary: { text: id } + }, + todoProgress: overrides.todoProgress ?? null, + pendingRequestsCount: overrides.pendingRequestsCount ?? 0, + permissionMode: overrides.permissionMode, + modelMode: overrides.modelMode + } +} + +function renderList(sessions: SessionSummary[], machineLabelsById?: Record) { + return render( + + + + ) +} + +describe('SessionList', () => { + it('groups sessions by machine and directory', () => { + const sessions = [ + makeSession({ + id: 's1', + metadata: { path: '/repo/app', machineId: 'm1', flavor: 'claude' }, + updatedAt: 100 + }), + makeSession({ + id: 's2', + metadata: { path: '/repo/app', machineId: 'm2', flavor: 'claude' }, + updatedAt: 90 + }) + ] + + renderList(sessions, { m1: 'Laptop', m2: 'Server' }) + + expect(screen.getByText('Laptop')).toBeInTheDocument() + expect(screen.getByText('Server')).toBeInTheDocument() + }) + + it('shows permission badge only when mode allowed for flavor', () => { + const sessions = [ + makeSession({ + id: 'claude-plan', + metadata: { path: '/repo/claude', machineId: 'm1', flavor: 'claude' }, + permissionMode: 'plan' + }), + makeSession({ + id: 'codex-plan', + metadata: { path: '/repo/codex', machineId: 'm1', flavor: 'codex' }, + permissionMode: 'plan' + }) + ] + + renderList(sessions, { m1: 'Laptop' }) + + expect(screen.getByText('plan mode')).toBeInTheDocument() + const codexRow = screen.getAllByText('codex')[0]?.closest('button') + expect(codexRow).toBeTruthy() + expect(codexRow?.textContent?.toLowerCase()).not.toContain('plan mode') + }) +}) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 69c71c37b..428e5d47b 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import type { SessionSummary } from '@/types/api' import type { ApiClient } from '@/api/client' +import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { useLongPress } from '@/hooks/useLongPress' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' @@ -8,10 +9,13 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { useTranslation } from '@/lib/use-translation' +import { getFlavorTextClass, PERMISSION_TONE_TEXT } from '@/lib/agentFlavorUtils' type SessionGroup = { + key: string directory: string displayName: string + machineId: string | null sessions: SessionSummary[] latestUpdatedAt: number hasActiveSession: boolean @@ -26,32 +30,46 @@ function getGroupDisplayName(directory: string): string { } function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { - const groups = new Map() + const groups = new Map() sessions.forEach(session => { const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other' - if (!groups.has(path)) { - groups.set(path, []) + const machineId = session.metadata?.machineId ?? null + const key = `${machineId ?? '__unknown__'}::${path}` + if (!groups.has(key)) { + groups.set(key, { + directory: path, + machineId, + sessions: [] + }) } - groups.get(path)!.push(session) + groups.get(key)!.sessions.push(session) }) return Array.from(groups.entries()) - .map(([directory, groupSessions]) => { - const sortedSessions = [...groupSessions].sort((a, b) => { + .map(([key, group]) => { + const sortedSessions = [...group.sessions].sort((a, b) => { const rankA = a.active ? (a.pendingRequestsCount > 0 ? 0 : 1) : 2 const rankB = b.active ? (b.pendingRequestsCount > 0 ? 0 : 1) : 2 if (rankA !== rankB) return rankA - rankB return b.updatedAt - a.updatedAt }) - const latestUpdatedAt = groupSessions.reduce( + const latestUpdatedAt = group.sessions.reduce( (max, s) => (s.updatedAt > max ? s.updatedAt : max), -Infinity ) - const hasActiveSession = groupSessions.some(s => s.active) - const displayName = getGroupDisplayName(directory) + const hasActiveSession = group.sessions.some(s => s.active) + const displayName = getGroupDisplayName(group.directory) - return { directory, displayName, sessions: sortedSessions, latestUpdatedAt, hasActiveSession } + return { + key, + directory: group.directory, + displayName, + machineId: group.machineId, + sessions: sortedSessions, + latestUpdatedAt, + hasActiveSession + } }) .sort((a, b) => { if (a.hasActiveSession !== b.hasActiveSession) { @@ -147,6 +165,27 @@ function getAgentLabel(session: SessionSummary): string { return 'unknown' } +function MachineIcon(props: { className?: string }) { + return ( + + + + + + ) +} + function formatRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value if (!Number.isFinite(ms)) return null @@ -201,20 +240,34 @@ function SessionItem(props: { const statusDotClass = s.active ? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' + + const flavor = s.metadata?.flavor?.trim() ?? null + const flavorTextClass = getFlavorTextClass(flavor) + + const permMode = s.permissionMode + && s.permissionMode !== 'default' + && isPermissionModeAllowedForFlavor(s.permissionMode, flavor) + ? s.permissionMode + : null + const permLabel = permMode ? getPermissionModeLabel(permMode).toLowerCase() : null + const permTone = permMode ? getPermissionModeTone(permMode) : null + const permTextClass = permTone ? PERMISSION_TONE_TEXT[permTone] : '' + const todoProgress = getTodoProgress(s) + return ( <> @@ -319,10 +383,11 @@ export function SessionList(props: { isLoading: boolean renderHeader?: boolean api: ApiClient | null + machineLabelsById?: Record selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId } = props + const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] @@ -330,16 +395,25 @@ export function SessionList(props: { const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + const resolveMachineLabel = (machineId: string | null): string => { + if (machineId && machineLabelsById[machineId]) { + return machineLabelsById[machineId] + } + if (machineId) { + return machineId.slice(0, 8) + } + return t('machine.unknown') + } const isGroupCollapsed = (group: SessionGroup): boolean => { - const override = collapseOverrides.get(group.directory) + const override = collapseOverrides.get(group.key) if (override !== undefined) return override return !group.hasActiveSession } - const toggleGroup = (directory: string, isCollapsed: boolean) => { + const toggleGroup = (groupKey: string, isCollapsed: boolean) => { setCollapseOverrides(prev => { const next = new Map(prev) - next.set(directory, !isCollapsed) + next.set(groupKey, !isCollapsed) return next }) } @@ -348,11 +422,11 @@ export function SessionList(props: { setCollapseOverrides(prev => { if (prev.size === 0) return prev const next = new Map(prev) - const knownGroups = new Set(groups.map(group => group.directory)) + const knownGroups = new Set(groups.map(group => group.key)) let changed = false - for (const directory of next.keys()) { - if (!knownGroups.has(directory)) { - next.delete(directory) + for (const groupKey of next.keys()) { + if (!knownGroups.has(groupKey)) { + next.delete(groupKey) changed = true } } @@ -381,28 +455,38 @@ export function SessionList(props: {
{groups.map((group) => { const isCollapsed = isGroupCollapsed(group) + const groupMachineLabel = resolveMachineLabel(group.machineId) return ( -
+
{!isCollapsed ? ( -
+
{group.sessions.map((s) => ( ({ + getTelegramWebApp: vi.fn(() => null) +})) + +function createMatchMediaStub(initialDark = false) { + const listeners = new Set() + const mediaQuery = { + media: '(prefers-color-scheme: dark)', + matches: initialDark, + onchange: null, + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + listeners.add(listener) + }, + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + listeners.delete(listener) + }, + dispatchEvent: () => true, + addListener: () => {}, + removeListener: () => {}, + } as MediaQueryList + + const setDark = (next: boolean) => { + ;(mediaQuery as { matches: boolean }).matches = next + const event = { matches: next } as MediaQueryListEvent + listeners.forEach((listener) => { + if (typeof listener === 'function') { + listener.call(mediaQuery, event) + return + } + listener.handleEvent(event) + }) + } + + return { mediaQuery, setDark } +} + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + }) + + it('initializeTheme applies stored theme preference', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'catpuccin') + + initializeTheme() + + expect(document.documentElement.getAttribute('data-theme')).toBe('catpuccin') + }) + + it('initializeTheme resolves gaius against system scheme', () => { + const { mediaQuery } = createMatchMediaStub(true) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'gaius') + + initializeTheme() + + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-dark') + }) + + it('initializeTheme keeps system theme reactive without mounted hook', () => { + const { mediaQuery, setDark } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.removeItem('hapi-theme') + + initializeTheme() + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + + act(() => setDark(true)) + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + }) + + it('persists and clears theme preference from hook setter', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + + const { result } = renderHook(() => useTheme()) + act(() => result.current.setThemePreference('dark')) + expect(localStorage.getItem('hapi-theme')).toBe('dark') + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + + act(() => result.current.setThemePreference('system')) + expect(localStorage.getItem('hapi-theme')).toBeNull() + }) + + it('updates resolved gaius theme when system preference changes', () => { + const { mediaQuery, setDark } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'gaius') + + initializeTheme() + renderHook(() => useTheme()) + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-light') + + act(() => setDark(true)) + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-dark') + }) + + it('shares theme preference state across hook consumers', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + initializeTheme() + + const first = renderHook(() => useTheme()) + const second = renderHook(() => useTheme()) + + act(() => first.result.current.setThemePreference('dark')) + expect(second.result.current.themePreference).toBe('dark') + }) +}) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 25457ca99..0f3c4a873 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,28 +1,74 @@ -import { useSyncExternalStore } from 'react' +import { useCallback, useEffect, useLayoutEffect, useSyncExternalStore } from 'react' import { getTelegramWebApp } from './useTelegram' -type ColorScheme = 'light' | 'dark' +export type ThemePreference = 'system' | 'light' | 'dark' | 'catpuccin' | 'gaius' | 'gaius-light' | 'gaius-dark' +type ResolvedTheme = 'light' | 'dark' | 'catpuccin' | 'gaius-light' | 'gaius-dark' -function getColorScheme(): ColorScheme { +const STORAGE_KEY = 'hapi-theme' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect + +function safeGetItem(key: string): string | null { + if (!isBrowser()) return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) return + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) return + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseThemePreference(raw: string | null): ThemePreference { + if (raw === 'light' || raw === 'dark' || raw === 'catpuccin' || raw === 'gaius' || raw === 'gaius-light' || raw === 'gaius-dark') return raw + return 'system' +} + +function getSystemColorScheme(): 'light' | 'dark' { const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' } - - // Fallback to system preference for browser environment - if (typeof window !== 'undefined' && window.matchMedia) { + if (isBrowser() && window.matchMedia) { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } - return 'light' } -function isIOS(): boolean { - return /iPad|iPhone|iPod/.test(navigator.userAgent) +function resolveTheme(pref: ThemePreference): ResolvedTheme { + if (pref === 'system') return getSystemColorScheme() + if (pref === 'gaius') return getSystemColorScheme() === 'dark' ? 'gaius-dark' : 'gaius-light' + return pref +} + +function applyTheme(theme: ResolvedTheme): void { + if (!isBrowser()) return + document.documentElement.setAttribute('data-theme', theme) } -function applyTheme(scheme: ColorScheme): void { - document.documentElement.setAttribute('data-theme', scheme) +function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return /iPad|iPhone|iPod/.test(navigator.userAgent) } function applyPlatform(): void { @@ -31,59 +77,141 @@ function applyPlatform(): void { } } -// External store for theme state -let currentScheme: ColorScheme = getColorScheme() -const listeners = new Set<() => void>() +function getInitialPreference(): ThemePreference { + return parseThemePreference(safeGetItem(STORAGE_KEY)) +} + +function isSystemLinkedPreference(pref: ThemePreference): boolean { + return pref === 'system' || pref === 'gaius' +} -// Apply theme immediately at module load (before React renders) -applyTheme(currentScheme) +let themePreferenceState: ThemePreference = getInitialPreference() +const themeSubscribers = new Set<() => void>() +let storageSyncInitialized = false +let mediaQueryListenerInitialized = false +let telegramThemeListenerInitialized = false +let mediaQueryList: MediaQueryList | null = null -function subscribe(callback: () => void): () => void { - listeners.add(callback) - return () => listeners.delete(callback) +function notifyThemeSubscribers(): void { + themeSubscribers.forEach((subscriber) => subscriber()) } -function getSnapshot(): ColorScheme { - return currentScheme +function applyThemePreference(pref: ThemePreference): void { + themePreferenceState = pref + applyTheme(resolveTheme(pref)) } -function updateScheme(): void { - const newScheme = getColorScheme() - if (newScheme !== currentScheme) { - currentScheme = newScheme - applyTheme(newScheme) - listeners.forEach((cb) => cb()) +function persistThemePreference(pref: ThemePreference): void { + if (pref === 'system') { + safeRemoveItem(STORAGE_KEY) + return } + safeSetItem(STORAGE_KEY, pref) } -// Track if theme listeners have been set up -let listenersInitialized = false - -export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { - const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +function setThemePreferenceState(pref: ThemePreference, options?: { persist?: boolean }): void { + const previous = themePreferenceState + applyThemePreference(pref) + if (options?.persist !== false) { + persistThemePreference(pref) + } + if (previous !== pref) { + notifyThemeSubscribers() + } +} - return { - colorScheme, - isDark: colorScheme === 'dark', +function onSystemThemeChanged(): void { + if (!isSystemLinkedPreference(themePreferenceState)) { + return } + applyTheme(resolveTheme(themePreferenceState)) + notifyThemeSubscribers() } -// Call this once at app startup to ensure theme is applied and listeners attached -export function initializeTheme(): void { - currentScheme = getColorScheme() - applyTheme(currentScheme) +function ensureThemeListeners(): void { + if (!isBrowser()) return + + if (!storageSyncInitialized) { + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY) return + const next = parseThemePreference(event.newValue) + setThemePreferenceState(next, { persist: false }) + } + window.addEventListener('storage', onStorage) + storageSyncInitialized = true + } + + if (window.matchMedia) { + const nextMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + if (!mediaQueryListenerInitialized || mediaQueryList !== nextMediaQuery) { + mediaQueryList?.removeEventListener('change', onSystemThemeChanged) + nextMediaQuery.addEventListener('change', onSystemThemeChanged) + mediaQueryList = nextMediaQuery + mediaQueryListenerInitialized = true + } + } - // Set up listeners only once (after SDK may have loaded) - if (!listenersInitialized) { - listenersInitialized = true + if (!telegramThemeListenerInitialized) { const tg = getTelegramWebApp() if (tg?.onEvent) { - // Telegram theme changes - tg.onEvent('themeChanged', updateScheme) - } else if (typeof window !== 'undefined' && window.matchMedia) { - // Browser system preference changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - mediaQuery.addEventListener('change', updateScheme) + tg.onEvent('themeChanged', onSystemThemeChanged) + telegramThemeListenerInitialized = true } } } + +export function initializeTheme(): void { + themePreferenceState = getInitialPreference() + applyPlatform() + applyTheme(resolveTheme(themePreferenceState)) + ensureThemeListeners() +} + +export function getThemeOptions(): ReadonlyArray<{ value: ThemePreference; label: string }> { + return [ + { value: 'system', label: 'system' }, + { value: 'light', label: 'light' }, + { value: 'dark', label: 'dark' }, + { value: 'catpuccin', label: 'catpuccin' }, + { value: 'gaius', label: 'gaius' }, + { value: 'gaius-light', label: 'gaius-light' }, + { value: 'gaius-dark', label: 'gaius-dark' }, + ] +} + +export function useTheme(): { + themePreference: ThemePreference + setThemePreference: (pref: ThemePreference) => void + isDark: boolean +} { + const themePreference = useSyncExternalStore( + (subscriber) => { + ensureThemeListeners() + themeSubscribers.add(subscriber) + return () => { + themeSubscribers.delete(subscriber) + } + }, + () => themePreferenceState, + () => themePreferenceState + ) + const resolved = resolveTheme(themePreference) + + useIsomorphicLayoutEffect(() => { + applyTheme(resolved) + }, [resolved]) + + useEffect(() => { + ensureThemeListeners() + }, []) + + const setThemePreference = useCallback((pref: ThemePreference) => { + setThemePreferenceState(pref) + }, []) + + return { + themePreference, + setThemePreference, + isDark: resolved !== 'light' && resolved !== 'gaius-light', + } +} diff --git a/web/src/index.css b/web/src/index.css index d74b031bd..21ac74356 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11,6 +11,7 @@ --app-banner-bg: var(--tg-theme-button-color, #111827); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #f3f4f6); + --app-selected-bg: rgba(59, 130, 246, 0.08); /* Theme-aware colors (light mode defaults) */ --app-border: rgba(0, 0, 0, 0.1); @@ -33,6 +34,9 @@ --app-git-untracked-color: #8E8E93; /* Badge colors (light) */ + --app-badge-info-bg: rgba(59, 130, 246, 0.15); + --app-badge-info-text: #1d4ed8; + --app-badge-info-border: rgba(59, 130, 246, 0.25); --app-badge-warning-bg: rgba(245, 158, 11, 0.2); --app-badge-warning-text: #b45309; --app-badge-warning-border: rgba(245, 158, 11, 0.3); @@ -43,6 +47,22 @@ --app-badge-error-text: #b91c1c; --app-badge-error-border: rgba(239, 68, 68, 0.3); + --app-perm-warning: #c2410c; + + /* Agent flavor colors (light) */ + --app-flavor-claude: #92400e; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-border: rgba(245, 158, 11, 0.25); + --app-flavor-codex: #065f46; + --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); + --app-flavor-codex-border: rgba(16, 185, 129, 0.25); + --app-flavor-gemini: #1e40af; + --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); + --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); + --app-flavor-opencode: #5b21b6; + --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); + --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); + --app-font-scale: 1; } @@ -57,6 +77,7 @@ --app-banner-bg: var(--tg-theme-button-color, #3A3A3C); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #2C2C2E); + --app-selected-bg: rgba(59, 130, 246, 0.08); --app-border: rgba(255, 255, 255, 0.1); --app-divider: rgba(255, 255, 255, 0.08); @@ -78,6 +99,9 @@ --app-git-untracked-color: #9ca3af; /* Badge colors (dark) */ + --app-badge-info-bg: rgba(96, 165, 250, 0.2); + --app-badge-info-text: #60a5fa; + --app-badge-info-border: rgba(96, 165, 250, 0.3); --app-badge-warning-bg: rgba(251, 191, 36, 0.2); --app-badge-warning-text: #fbbf24; --app-badge-warning-border: rgba(251, 191, 36, 0.3); @@ -87,6 +111,213 @@ --app-badge-error-bg: rgba(248, 113, 113, 0.2); --app-badge-error-text: #fca5a5; --app-badge-error-border: rgba(248, 113, 113, 0.35); + + --app-perm-warning: #fb923c; + + /* Agent flavor colors (dark) */ + --app-flavor-claude: #fbbf24; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-border: rgba(245, 158, 11, 0.25); + --app-flavor-codex: #34d399; + --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); + --app-flavor-codex-border: rgba(16, 185, 129, 0.25); + --app-flavor-gemini: #60a5fa; + --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); + --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); + --app-flavor-opencode: #a78bfa; + --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); + --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); +} + +[data-theme="catpuccin"] { + /* Primary colors — Catpuccin Mocha */ + --app-bg: #1e1e2e; + --app-fg: #cdd6f4; + --app-hint: #6c7086; + --app-link: #cdd6f4; + --app-button: #cdd6f4; + --app-button-text: #1e1e2e; + --app-banner-bg: #313244; + --app-banner-text: #cdd6f4; + --app-secondary-bg: #313244; + --app-selected-bg: rgba(137, 180, 250, 0.06); + + --app-border: rgba(255, 255, 255, 0.1); + --app-divider: rgba(255, 255, 255, 0.08); + --app-subtle-bg: rgba(255, 255, 255, 0.05); + --app-code-bg: #282c34; + --app-inline-code-bg: rgba(255, 255, 255, 0.1); + + /* Diff colors (dark) */ + --app-diff-added-bg: #0d2e1f; + --app-diff-added-text: #c9d1d9; + --app-diff-removed-bg: #3f1b23; + --app-diff-removed-text: #c9d1d9; + + /* Git status colors */ + --app-git-staged-color: #a6e3a1; + --app-git-unstaged-color: #fab387; + --app-git-deleted-color: #f38ba8; + --app-git-renamed-color: #89b4fa; + --app-git-untracked-color: #6c7086; + + /* Badge colors — Catpuccin Mocha */ + --app-badge-info-bg: rgba(137, 180, 250, 0.12); + --app-badge-info-text: #89b4fa; + --app-badge-info-border: rgba(137, 180, 250, 0.22); + --app-badge-warning-bg: rgba(250, 179, 135, 0.15); + --app-badge-warning-text: #fab387; + --app-badge-warning-border: rgba(250, 179, 135, 0.25); + --app-badge-success-bg: rgba(166, 227, 161, 0.15); + --app-badge-success-text: #a6e3a1; + --app-badge-success-border: rgba(166, 227, 161, 0.25); + --app-badge-error-bg: rgba(243, 139, 168, 0.15); + --app-badge-error-text: #f38ba8; + --app-badge-error-border: rgba(243, 139, 168, 0.25); + + --app-perm-warning: #fab387; + + /* Agent flavor colors — Catpuccin Mocha */ + --app-flavor-claude: #fab387; + --app-flavor-claude-bg: rgba(250, 179, 135, 0.10); + --app-flavor-claude-border: rgba(250, 179, 135, 0.20); + --app-flavor-codex: #a6e3a1; + --app-flavor-codex-bg: rgba(166, 227, 161, 0.10); + --app-flavor-codex-border: rgba(166, 227, 161, 0.20); + --app-flavor-gemini: #74c7ec; + --app-flavor-gemini-bg: rgba(116, 199, 236, 0.10); + --app-flavor-gemini-border: rgba(116, 199, 236, 0.20); + --app-flavor-opencode: #cba6f7; + --app-flavor-opencode-bg: rgba(203, 166, 247, 0.10); + --app-flavor-opencode-border: rgba(203, 166, 247, 0.20); +} + +[data-theme="gaius-light"] { + /* Primary — warm pearl base */ + --app-bg: #f8f5f2; + --app-fg: #2a2832; + --app-hint: #85808a; + --app-link: #b04440; + --app-button: #2a2832; + --app-button-text: #f8f5f2; + --app-banner-bg: #eceae6; + --app-banner-text: #2a2832; + --app-secondary-bg: #f0ede9; + --app-selected-bg: rgba(176, 68, 64, 0.06); + + /* Overlays */ + --app-border: rgba(42, 40, 50, 0.10); + --app-divider: rgba(42, 40, 50, 0.07); + --app-subtle-bg: rgba(42, 40, 50, 0.03); + --app-code-bg: #f0ede8; + --app-inline-code-bg: rgba(42, 40, 50, 0.05); + + /* Diffs — verdigris added, cinnabar removed */ + --app-diff-added-bg: #e4edd8; + --app-diff-added-text: #2a2832; + --app-diff-removed-bg: #f2dcd8; + --app-diff-removed-text: #2a2832; + + /* Git status */ + --app-git-staged-color: #3a7868; + --app-git-unstaged-color: #b07830; + --app-git-deleted-color: #b84440; + --app-git-renamed-color: #4068a0; + --app-git-untracked-color: #85808a; + + /* Badges — lapis, gold, verdigris, cinnabar */ + --app-badge-info-bg: rgba(64, 104, 160, 0.10); + --app-badge-info-text: #3a5888; + --app-badge-info-border: rgba(64, 104, 160, 0.20); + --app-badge-warning-bg: rgba(176, 120, 48, 0.12); + --app-badge-warning-text: #8a5820; + --app-badge-warning-border: rgba(176, 120, 48, 0.22); + --app-badge-success-bg: rgba(58, 120, 104, 0.10); + --app-badge-success-text: #2a6858; + --app-badge-success-border: rgba(58, 120, 104, 0.20); + --app-badge-error-bg: rgba(184, 68, 64, 0.10); + --app-badge-error-text: #983838; + --app-badge-error-border: rgba(184, 68, 64, 0.20); + + --app-perm-warning: #b84430; + + /* Agent flavors — cinnabar, verdigris, lapis, violet */ + --app-flavor-claude: #a04038; + --app-flavor-claude-bg: rgba(160, 64, 56, 0.08); + --app-flavor-claude-border: rgba(160, 64, 56, 0.18); + --app-flavor-codex: #2a6858; + --app-flavor-codex-bg: rgba(42, 104, 88, 0.08); + --app-flavor-codex-border: rgba(42, 104, 88, 0.18); + --app-flavor-gemini: #3a5888; + --app-flavor-gemini-bg: rgba(58, 88, 136, 0.08); + --app-flavor-gemini-border: rgba(58, 88, 136, 0.18); + --app-flavor-opencode: #6a5090; + --app-flavor-opencode-bg: rgba(106, 80, 144, 0.08); + --app-flavor-opencode-border: rgba(106, 80, 144, 0.18); +} + +[data-theme="gaius-dark"] { + /* Primary — deep slate base */ + --app-bg: #1e1d22; + --app-fg: #e6e3de; + --app-hint: #88848e; + --app-link: #d06058; + --app-button: #e6e3de; + --app-button-text: #1e1d22; + --app-banner-bg: #2a2930; + --app-banner-text: #e6e3de; + --app-secondary-bg: #252428; + --app-selected-bg: rgba(208, 96, 88, 0.06); + + /* Overlays */ + --app-border: rgba(230, 227, 222, 0.08); + --app-divider: rgba(230, 227, 222, 0.06); + --app-subtle-bg: rgba(230, 227, 222, 0.04); + --app-code-bg: #252430; + --app-inline-code-bg: rgba(230, 227, 222, 0.07); + + /* Diffs */ + --app-diff-added-bg: rgba(80, 150, 130, 0.12); + --app-diff-added-text: #d8d5d0; + --app-diff-removed-bg: rgba(200, 80, 70, 0.12); + --app-diff-removed-text: #d8d5d0; + + /* Git status */ + --app-git-staged-color: #68b8a0; + --app-git-unstaged-color: #d0a060; + --app-git-deleted-color: #d87068; + --app-git-renamed-color: #6890c8; + --app-git-untracked-color: #88848e; + + /* Badges */ + --app-badge-info-bg: rgba(104, 144, 200, 0.12); + --app-badge-info-text: #6890c8; + --app-badge-info-border: rgba(104, 144, 200, 0.20); + --app-badge-warning-bg: rgba(208, 160, 96, 0.15); + --app-badge-warning-text: #d0a060; + --app-badge-warning-border: rgba(208, 160, 96, 0.25); + --app-badge-success-bg: rgba(104, 184, 160, 0.10); + --app-badge-success-text: #68b8a0; + --app-badge-success-border: rgba(104, 184, 160, 0.20); + --app-badge-error-bg: rgba(216, 112, 104, 0.12); + --app-badge-error-text: #d87068; + --app-badge-error-border: rgba(216, 112, 104, 0.22); + + --app-perm-warning: #d08050; + + /* Agent flavors */ + --app-flavor-claude: #d08858; + --app-flavor-claude-bg: rgba(208, 136, 88, 0.10); + --app-flavor-claude-border: rgba(208, 136, 88, 0.20); + --app-flavor-codex: #68b8a0; + --app-flavor-codex-bg: rgba(104, 184, 160, 0.10); + --app-flavor-codex-border: rgba(104, 184, 160, 0.20); + --app-flavor-gemini: #6890c8; + --app-flavor-gemini-bg: rgba(104, 144, 200, 0.10); + --app-flavor-gemini-border: rgba(104, 144, 200, 0.20); + --app-flavor-opencode: #a088c0; + --app-flavor-opencode-bg: rgba(160, 136, 192, 0.10); + --app-flavor-opencode-border: rgba(160, 136, 192, 0.20); } html { @@ -167,7 +398,11 @@ body { } html[data-theme="dark"] .shiki, -html[data-theme="dark"] .shiki span { +html[data-theme="dark"] .shiki span, +html[data-theme="catpuccin"] .shiki, +html[data-theme="catpuccin"] .shiki span, +html[data-theme="gaius-dark"] .shiki, +html[data-theme="gaius-dark"] .shiki span { color: var(--shiki-dark) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index d190e38c9..8827d4791 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -1,3 +1,19 @@ +import type { PermissionModeTone } from '@hapi/protocol' + +export const PERMISSION_TONE_BADGE: Record = { + neutral: 'text-[var(--app-fg)] bg-[var(--app-subtle-bg)] border-[var(--app-border)]', + info: 'text-[var(--app-badge-info-text)] bg-[var(--app-badge-info-bg)] border-[var(--app-badge-info-border)]', + warning: 'text-[var(--app-perm-warning)] bg-[var(--app-badge-warning-bg)] border-[var(--app-badge-warning-border)]', + danger: 'text-[var(--app-badge-error-text)] bg-[var(--app-badge-error-bg)] border-[var(--app-badge-error-border)]' +} + +export const PERMISSION_TONE_TEXT: Record = { + neutral: 'text-[var(--app-fg)]', + info: 'text-[var(--app-badge-info-text)]', + warning: 'text-[var(--app-perm-warning)]', + danger: 'text-[var(--app-badge-error-text)]' +} + export function isCodexFamilyFlavor(flavor?: string | null): boolean { return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' } @@ -9,3 +25,65 @@ export function isClaudeFlavor(flavor?: string | null): boolean { export function isKnownFlavor(flavor?: string | null): boolean { return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) } + +type FlavorColors = { + text: string + bg: string + border: string +} + +const FLAVOR_COLORS: Record = { + claude: { + text: 'text-[var(--app-flavor-claude)]', + bg: 'bg-[var(--app-flavor-claude-bg)]', + border: 'border-[var(--app-flavor-claude-border)]' + }, + codex: { + text: 'text-[var(--app-flavor-codex)]', + bg: 'bg-[var(--app-flavor-codex-bg)]', + border: 'border-[var(--app-flavor-codex-border)]' + }, + gemini: { + text: 'text-[var(--app-flavor-gemini)]', + bg: 'bg-[var(--app-flavor-gemini-bg)]', + border: 'border-[var(--app-flavor-gemini-border)]' + }, + opencode: { + text: 'text-[var(--app-flavor-opencode)]', + bg: 'bg-[var(--app-flavor-opencode-bg)]', + border: 'border-[var(--app-flavor-opencode-border)]' + } +} + +const DEFAULT_FLAVOR_COLORS: FlavorColors = { + text: 'text-[var(--app-hint)]', + bg: 'bg-[var(--app-subtle-bg)]', + border: 'border-[var(--app-border)]' +} + +export function getFlavorColors(flavor?: string | null): FlavorColors { + const key = flavor?.trim() + if (key && FLAVOR_COLORS[key]) return FLAVOR_COLORS[key] + return DEFAULT_FLAVOR_COLORS +} + +export function getFlavorBadgeClass(flavor?: string | null): string { + const colors = getFlavorColors(flavor) + return `${colors.text} ${colors.bg} ${colors.border}` +} + +export function getFlavorTextClass(flavor?: string | null): string { + const colors = getFlavorColors(flavor) + return colors.text +} + +export function getFlavorDotClass(flavor?: string | null): string { + const key = flavor?.trim() + switch (key) { + case 'claude': return 'bg-[var(--app-flavor-claude)]' + case 'codex': return 'bg-[var(--app-flavor-codex)]' + case 'gemini': return 'bg-[var(--app-flavor-gemini)]' + case 'opencode': return 'bg-[var(--app-flavor-opencode)]' + default: return 'bg-[var(--app-hint)]' + } +} diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 000000000..2a28276c9 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { safeCopyToClipboard } from './clipboard' + +describe('safeCopyToClipboard', () => { + beforeEach(() => { + vi.restoreAllMocks() + Object.defineProperty(document, 'execCommand', { + configurable: true, + writable: true, + value: vi.fn(() => false) + }) + }) + + it('uses navigator clipboard writeText when available', async () => { + const writeText = vi.fn(async () => {}) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }) + const execCommand = vi.mocked(document.execCommand) + execCommand.mockReturnValue(true) + + await safeCopyToClipboard('hello') + + expect(writeText).toHaveBeenCalledWith('hello') + expect(execCommand).not.toHaveBeenCalled() + }) + + it('falls back to execCommand when clipboard api write fails', async () => { + const writeText = vi.fn(async () => { + throw new Error('clipboard denied') + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }) + const execCommand = vi.mocked(document.execCommand) + execCommand.mockReturnValue(true) + + await safeCopyToClipboard('fallback') + + expect(writeText).toHaveBeenCalledWith('fallback') + expect(execCommand).toHaveBeenCalledWith('copy') + }) + + it('throws when both modern and legacy copy strategies fail', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined + }) + const execCommand = vi.mocked(document.execCommand) + execCommand.mockReturnValue(false) + + await expect(safeCopyToClipboard('x')).rejects.toThrow('Copy to clipboard failed') + }) +}) diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts index 79dc74b73..38a29e87b 100644 --- a/web/src/lib/clipboard.ts +++ b/web/src/lib/clipboard.ts @@ -1,6 +1,62 @@ -export function safeCopyToClipboard(text: string): Promise { - if (navigator.clipboard?.writeText) { - return navigator.clipboard.writeText(text) +function copyWithExecCommand(text: string): boolean { + if (typeof document === 'undefined' || !document.body) { + return false } - return Promise.reject(new Error('Clipboard API not available')) + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'fixed' + textarea.style.top = '0' + textarea.style.left = '0' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.padding = '0' + textarea.style.border = '0' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null + const selection = document.getSelection() + const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null + + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, textarea.value.length) + + let copied = false + try { + copied = document.execCommand('copy') + } catch { + copied = false + } finally { + document.body.removeChild(textarea) + if (selection) { + selection.removeAllRanges() + if (previousRange) { + selection.addRange(previousRange) + } + } + activeElement?.focus() + } + + return copied +} + +export async function safeCopyToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return + } catch { + // Fall through to legacy copy strategy. + } + } + + if (copyWithExecCommand(text)) { + return + } + + throw new Error('Copy to clipboard failed') } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 832756124..3e7d96991 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -88,6 +88,7 @@ export default { 'button.close': 'Close', 'button.dismiss': 'Dismiss', 'button.copy': 'Copy', + 'button.paste': 'Paste', // New session form 'newSession.machine': 'Machine', @@ -136,6 +137,9 @@ export default { 'terminal.commandArgs': 'Command args', 'terminal.stdout': 'Stdout', 'terminal.stderr': 'Stderr', + 'terminal.paste.fallbackTitle': 'Paste input', + 'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.', + 'terminal.paste.placeholder': 'Paste terminal input here…', // Code block 'code.copy': 'Copy', @@ -241,6 +245,14 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.theme': 'Theme', + 'settings.display.theme.system': 'System', + 'settings.display.theme.light': 'Light', + 'settings.display.theme.dark': 'Dark', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius Light', + 'settings.display.theme.gaius-dark': 'Gaius Dark', 'settings.display.fontSize': 'Font Size', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 48c75d513..5b407b9ea 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -90,6 +90,7 @@ export default { 'button.close': '关闭', 'button.dismiss': '忽略', 'button.copy': '复制', + 'button.paste': '粘贴', // New session form 'newSession.machine': '机器', @@ -138,6 +139,9 @@ export default { 'terminal.commandArgs': '命令参数', 'terminal.stdout': '标准输出', 'terminal.stderr': '标准错误', + 'terminal.paste.fallbackTitle': '粘贴输入', + 'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。', + 'terminal.paste.placeholder': '在此粘贴终端输入…', // Code block 'code.copy': '复制', @@ -243,6 +247,14 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.theme': '主题', + 'settings.display.theme.system': '跟随系统', + 'settings.display.theme.light': '浅色', + 'settings.display.theme.dark': '深色', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius 浅色', + 'settings.display.theme.gaius-dark': 'Gaius 深色', 'settings.display.fontSize': '字体大小', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', diff --git a/web/src/main.tsx b/web/src/main.tsx index b88697c06..3eba2a1f6 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,7 @@ import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import './index.css' import { registerSW } from 'virtual:pwa-register' import { initializeFontScale } from '@/hooks/useFontScale' +import { initializeTheme } from '@/hooks/useTheme' import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' import { queryClient } from './lib/query-client' import { createAppRouter } from './router' @@ -34,6 +35,7 @@ function getInitialPath(): string { async function bootstrap() { initializeFontScale() + initializeTheme() // Only load Telegram SDK in Telegram environment (with 3s timeout) const isTelegram = isTelegramEnvironment() diff --git a/web/src/router.tsx b/web/src/router.tsx index 0dd3ad155..3aec839f4 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Navigate, @@ -30,6 +30,7 @@ import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store' +import type { Machine } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -94,6 +95,12 @@ function SettingsIcon(props: { className?: string }) { ) } +function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + function SessionsPage() { const { api } = useAppContext() const navigate = useNavigate() @@ -101,12 +108,24 @@ function SessionsPage() { const matchRoute = useMatchRoute() const { t } = useTranslation() const { sessions, isLoading, error, refetch } = useSessions(api) + const { machines } = useMachines(api, true) const handleRefresh = useCallback(() => { void refetch() }, [refetch]) - const projectCount = new Set(sessions.map(s => s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other')).size + const projectCount = new Set(sessions.map(s => { + const path = s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' + const machineId = s.metadata?.machineId ?? '__unknown__' + return `${machineId}::${path}` + })).size + const machineLabelsById = useMemo(() => { + const labels: Record = {} + for (const machine of machines) { + labels[machine.id] = getMachineTitle(machine) + } + return labels + }, [machines]) const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' @@ -160,6 +179,7 @@ function SessionsPage() { isLoading={isLoading} renderHeader={false} api={api} + machineLabelsById={machineLabelsById} />
diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index 179a29b14..dd3a96bb6 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -11,6 +11,8 @@ import { queryKeys } from '@/lib/query-keys' import { langAlias, useShikiHighlighter } from '@/lib/shiki' import { decodeBase64 } from '@/lib/utils' +const MAX_COPYABLE_FILE_BYTES = 1_000_000 + function decodePath(value: string): string { if (!value) return '' const decoded = decodeBase64(value) @@ -94,6 +96,10 @@ function resolveLanguage(path: string): string | undefined { return langAlias[ext] ?? ext } +function getUtf8ByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + function isBinaryContent(content: string): boolean { if (!content) return false if (content.includes('\0')) return true @@ -112,7 +118,8 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n export default function FilePage() { const { api } = useAppContext() - const { copied, copy } = useCopyToClipboard() + const { copied: pathCopied, copy: copyPath } = useCopyToClipboard() + const { copied: contentCopied, copy: copyContent } = useCopyToClipboard() const goBack = useAppGoBack() const { sessionId } = useParams({ from: '/sessions/$sessionId/file' }) const search = useSearch({ from: '/sessions/$sessionId/file' }) @@ -160,6 +167,14 @@ export default function FilePage() { const language = useMemo(() => resolveLanguage(filePath), [filePath]) const highlighted = useShikiHighlighter(decodedContent, language) + const contentSizeBytes = useMemo( + () => (decodedContent ? getUtf8ByteLength(decodedContent) : 0), + [decodedContent] + ) + const canCopyContent = fileContentResult?.success === true + && !binaryFile + && decodedContent.length > 0 + && contentSizeBytes <= MAX_COPYABLE_FILE_BYTES const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') @@ -204,11 +219,11 @@ export default function FilePage() { {filePath}
@@ -257,9 +272,21 @@ export default function FilePage() {
{diffError}
) : displayMode === 'file' ? ( decodedContent ? ( -
-                                {highlighted ?? decodedContent}
-                            
+
+ {canCopyContent ? ( + + ) : null} +
+                                    {highlighted ?? decodedContent}
+                                
+
) : (
File is empty.
) diff --git a/web/src/routes/sessions/terminal.test.tsx b/web/src/routes/sessions/terminal.test.tsx new file mode 100644 index 000000000..2f294342e --- /dev/null +++ b/web/src/routes/sessions/terminal.test.tsx @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import TerminalPage from './terminal' + +const writeMock = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ sessionId: 'session-1' }) +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ + api: null, + token: 'test-token', + baseUrl: 'http://localhost:3000' + }) +})) + +vi.mock('@/hooks/useAppGoBack', () => ({ + useAppGoBack: () => vi.fn() +})) + +vi.mock('@/hooks/queries/useSession', () => ({ + useSession: () => ({ + session: { + id: 'session-1', + active: true, + metadata: { path: '/tmp/project' } + } + }) +})) + +vi.mock('@/hooks/useTerminalSocket', () => ({ + useTerminalSocket: () => ({ + state: { status: 'connected' as const }, + connect: vi.fn(), + write: writeMock, + resize: vi.fn(), + disconnect: vi.fn(), + onOutput: vi.fn(), + onExit: vi.fn() + }) +})) + +vi.mock('@/hooks/useLongPress', () => ({ + useLongPress: ({ onClick }: { onClick: () => void }) => ({ + onClick + }) +})) + +vi.mock('@/components/Terminal/TerminalView', () => ({ + TerminalView: () =>
+})) + +function renderWithProviders() { + return render( + + + + ) +} + +describe('TerminalPage paste behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not open manual paste dialog when clipboard text is empty', async () => { + const readText = vi.fn(async () => '') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { readText } + }) + + renderWithProviders() + fireEvent.click(screen.getAllByRole('button', { name: 'Paste' })[0]) + + await waitFor(() => { + expect(readText).toHaveBeenCalledTimes(1) + }) + expect(writeMock).not.toHaveBeenCalled() + expect(screen.queryByText('Paste input')).not.toBeInTheDocument() + }) + + it('opens manual paste dialog when clipboard read fails', async () => { + const readText = vi.fn(async () => { + throw new Error('blocked') + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { readText } + }) + + renderWithProviders() + fireEvent.click(screen.getAllByRole('button', { name: 'Paste' })[0]) + + expect(await screen.findByText('Paste input')).toBeInTheDocument() + }) +}) diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx index 5ccc9fe40..0aba34b6e 100644 --- a/web/src/routes/sessions/terminal.tsx +++ b/web/src/routes/sessions/terminal.tsx @@ -7,8 +7,17 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { useSession } from '@/hooks/queries/useSession' import { useTerminalSocket } from '@/hooks/useTerminalSocket' import { useLongPress } from '@/hooks/useLongPress' +import { useTranslation } from '@/lib/use-translation' import { TerminalView } from '@/components/Terminal/TerminalView' import { LoadingState } from '@/components/LoadingState' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' function BackIcon() { return ( (null) const [ctrlActive, setCtrlActive] = useState(false) const [altActive, setAltActive] = useState(false) + const [pasteDialogOpen, setPasteDialogOpen] = useState(false) + const [manualPasteText, setManualPasteText] = useState('') const { state: terminalState, @@ -312,6 +324,48 @@ export default function TerminalPage() { }, [terminalState.status]) const quickInputDisabled = !session?.active || terminalState.status !== 'connected' + const writePlainInput = useCallback((text: string) => { + if (!text || quickInputDisabled) { + return false + } + write(text) + resetModifiers() + terminalRef.current?.focus() + return true + }, [quickInputDisabled, write, resetModifiers]) + + const handlePasteAction = useCallback(async () => { + if (quickInputDisabled) { + return + } + const readClipboard = navigator.clipboard?.readText + if (readClipboard) { + try { + const clipboardText = await readClipboard.call(navigator.clipboard) + if (!clipboardText) { + return + } + if (writePlainInput(clipboardText)) { + return + } + } catch { + // Fall through to manual paste modal. + } + } + setManualPasteText('') + setPasteDialogOpen(true) + }, [quickInputDisabled, writePlainInput]) + + const handleManualPasteSubmit = useCallback(() => { + if (!manualPasteText.trim()) { + return + } + if (writePlainInput(manualPasteText)) { + setPasteDialogOpen(false) + setManualPasteText('') + } + }, [manualPasteText, writePlainInput]) + const handleQuickInput = useCallback( (sequence: string) => { if (quickInputDisabled) { @@ -406,6 +460,16 @@ export default function TerminalPage() {
+ {QUICK_INPUT_ROWS.map((row, rowIndex) => (
+ + { + setPasteDialogOpen(open) + if (!open) { + setManualPasteText('') + } + }} + > + + + {t('terminal.paste.fallbackTitle')} + + {t('terminal.paste.fallbackDescription')} + + +