diff --git a/bun.lock b/bun.lock index 0ed85e8c3..6dd484e38 100644 --- a/bun.lock +++ b/bun.lock @@ -882,6 +882,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.15.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-wwQM88HATws1quf94M6D4rbkWXNVfIcAMA2WKae+i8/be2vvAi8uuvfV7HHqsqeozUmMJ3NEC6zKYhEtlmMuoA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.15.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-vuQH9INKvqwVZ9JphUmEyzKz6zWxHy3GE4j0mDqTulO234nGEhljkN3Qod0d7bLVncmO53zSMcm+ahwZ3qL4OA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], 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/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 3728b2670..4fbb24828 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) { @@ -128,21 +130,52 @@ 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'} - +
+ {(() => { + const flavor = session.metadata?.flavor?.trim() ?? null + const flavorBadge = getFlavorBadgeClass(flavor) + const label = flavor || 'unknown' + return ( + + {label} + + ) + })()} + {(() => { + const flavor = session.metadata?.flavor?.trim() ?? null + const permMode = session.permissionMode + && session.permissionMode !== 'default' + && isPermissionModeAllowedForFlavor(session.permissionMode, flavor) + ? session.permissionMode + : null + if (!permMode) return null + const label = getPermissionModeLabel(permMode).toLowerCase() + const tone = getPermissionModeTone(permMode) + const badgeClass = PERMISSION_TONE_BADGE[tone] + return ( + + {label} + + ) + })()} + {(() => { + const flav = session.metadata?.flavor?.trim() ?? null + if (flav && flav !== 'claude') return null + return ( + + {session.modelMode || 'default'} + + ) + })()} {worktreeBranch ? ( - {t('session.item.worktree')}: {worktreeBranch} + + {worktreeBranch} + ) : null}
diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 69c71c37b..115a34f75 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,33 @@ 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] : '' + return ( <> @@ -319,10 +386,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 +398,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 +425,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 +458,38 @@ export function SessionList(props: {
{groups.map((group) => { const isCollapsed = isGroupCollapsed(group) + const groupMachineLabel = resolveMachineLabel(group.machineId) return ( -
+
{!isCollapsed ? ( -
+
{group.sessions.map((s) => ( void>() - -// Apply theme immediately at module load (before React renders) -applyTheme(currentScheme) - -function subscribe(callback: () => void): () => void { - listeners.add(callback) - return () => listeners.delete(callback) +function getInitialPreference(): ThemePreference { + return parseThemePreference(safeGetItem(STORAGE_KEY)) } -function getSnapshot(): ColorScheme { - return currentScheme +export function initializeTheme(): void { + applyPlatform() + applyTheme(resolveTheme(getInitialPreference())) } -function updateScheme(): void { - const newScheme = getColorScheme() - if (newScheme !== currentScheme) { - currentScheme = newScheme - applyTheme(newScheme) - listeners.forEach((cb) => cb()) - } +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' }, + ] } -// Track if theme listeners have been set up -let listenersInitialized = false +export function useTheme(): { + themePreference: ThemePreference + setThemePreference: (pref: ThemePreference) => void + isDark: boolean +} { + const [themePreference, setThemePreferenceState] = useState(getInitialPreference) -export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { - const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const resolved = resolveTheme(themePreference) - return { - colorScheme, - isDark: colorScheme === 'dark', - } -} + useIsomorphicLayoutEffect(() => { + applyTheme(resolved) + }, [resolved]) -// Call this once at app startup to ensure theme is applied and listeners attached -export function initializeTheme(): void { - currentScheme = getColorScheme() - applyTheme(currentScheme) + // Listen for system color scheme changes (matters when pref is 'system' or 'gaius') + useEffect(() => { + if (themePreference !== 'system' && themePreference !== 'gaius') return undefined - // Set up listeners only once (after SDK may have loaded) - if (!listenersInitialized) { - listenersInitialized = true 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) + const handler = () => applyTheme(resolveTheme(themePreference)) + tg.onEvent('themeChanged', handler) + return () => tg.offEvent?.('themeChanged', handler) } + + if (isBrowser() && window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const handler = () => applyTheme(resolveTheme(themePreference)) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + } + + return undefined + }, [themePreference]) + + // Cross-tab sync + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY) return + const next = parseThemePreference(event.newValue) + setThemePreferenceState(next) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setThemePreference = useCallback((pref: ThemePreference) => { + setThemePreferenceState(pref) + if (pref === 'system') { + safeRemoveItem(STORAGE_KEY) + } else { + safeSetItem(STORAGE_KEY, 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.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..987cd418d 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': 'Catpuccin', + '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..841bf0982 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': 'Catpuccin', + '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.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')} + + +